diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index acb7cbd92ba..b40ad853493 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -602,6 +602,36 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }); + it("respawns into the updated git root before requested channel persistence", async () => { + const { entrypoints } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async (root) => + makeOkUpdateResult({ + mode: "git", + root, + before: { sha: "old-sha", version: "2026.4.26" }, + after: { sha: "new-sha", version: "2026.4.27" }, + }), + }); + + await updateCommand({ channel: "dev", yes: true, restart: false }); + + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/node/), + [entrypoints[0], "update", "--no-restart", "--yes"], + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev", + OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev", + }), + }), + ); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + }); + it("keeps downgrade post-update work in the current process", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-root"); setupUpdatedRootRefresh({ @@ -686,6 +716,47 @@ describe("update-cli", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("post-core resume mode persists the requested update channel with the updated process", async () => { + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...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", + }); + + 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(runGatewayUpdate).not.toHaveBeenCalled(); + expect(replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: { + update: { + channel: "dev", + }, + }, + baseHash: "stable-hash", + }); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "dev", + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + }), + ); + }); + it("passes the update timeout budget into post-core plugin updates", async () => { await withEnvAsync( { @@ -1538,7 +1609,18 @@ describe("update-cli", () => { [expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"], expect.any(Object), ); - expect(updateNpmInstalledPlugins).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/node/), + [entryPath, "update", "--no-restart", "--yes"], + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + }), + }), + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); expect( vi .mocked(defaultRuntime.log) diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 52be8334a49..918dbaf894b 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -54,7 +54,6 @@ import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { stylePromptMessage } from "../../terminal/prompt-style.js"; import { theme } from "../../terminal/theme.js"; -import { pathExists } from "../../utils.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; import { formatCliCommand } from "../command-format.js"; import { installCompletion } from "../completion-runtime.js"; @@ -93,6 +92,7 @@ const SERVICE_REFRESH_TIMEOUT_MS = 60_000; const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000; const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; +const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ "OPENCLAW_HOME", @@ -1093,6 +1093,40 @@ async function runPostCorePluginUpdate(params: { }); } +async function persistRequestedUpdateChannel(params: { + configSnapshot: Awaited>; + requestedChannel: "stable" | "beta" | "dev" | null; +}): Promise>> { + if (!params.requestedChannel || !params.configSnapshot.valid) { + return params.configSnapshot; + } + const storedChannel = normalizeUpdateChannel(params.configSnapshot.config.update?.channel); + if (params.requestedChannel === storedChannel) { + return params.configSnapshot; + } + + const next = { + ...params.configSnapshot.sourceConfig, + update: { + ...params.configSnapshot.sourceConfig.update, + channel: params.requestedChannel, + }, + }; + await replaceConfigFile({ + nextConfig: next, + baseHash: params.configSnapshot.hash, + }); + return { + ...params.configSnapshot, + hash: undefined, + parsed: next, + sourceConfig: asResolvedSourceConfig(next), + resolved: asResolvedSourceConfig(next), + runtimeConfig: asRuntimeConfig(next), + config: asRuntimeConfig(next), + }; +} + async function writePostCorePluginUpdateResultFile( filePath: string | undefined, result: PostCorePluginUpdateResult, @@ -1125,10 +1159,11 @@ async function readPostCorePluginUpdateResultFile( async function continuePostCoreUpdateInFreshProcess(params: { root: string; channel: "stable" | "beta" | "dev"; + requestedChannel: "stable" | "beta" | "dev" | null; opts: UpdateCommandOptions; }): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> { - const entryPath = path.join(params.root, "dist", "entry.js"); - if (!(await pathExists(entryPath))) { + const entryPath = await resolveGatewayInstallEntrypoint(params.root); + if (!entryPath) { return { resumed: false }; } @@ -1158,6 +1193,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { ...disableUpdatedPackageCompileCacheEnv(process.env), [POST_CORE_UPDATE_ENV]: "1", [POST_CORE_UPDATE_CHANNEL_ENV]: params.channel, + ...(params.requestedChannel + ? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel } + : {}), ...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}), }, }); @@ -1195,7 +1233,23 @@ function shouldResumePostCoreUpdateInFreshProcess(params: { result: UpdateRunResult; downgradeRisk: boolean; }): boolean { - return isPackageManagerUpdateMode(params.result.mode) && !params.downgradeRisk; + if (params.downgradeRisk) { + return false; + } + if (isPackageManagerUpdateMode(params.result.mode)) { + return true; + } + if (params.result.mode !== "git") { + return false; + } + const beforeSha = normalizeOptionalString(params.result.before?.sha); + const afterSha = normalizeOptionalString(params.result.after?.sha); + if (beforeSha && afterSha && beforeSha !== afterSha) { + return true; + } + const beforeVersion = normalizeOptionalString(params.result.before?.version); + const afterVersion = normalizeOptionalString(params.result.after?.version); + return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion); } export async function updateCommand(opts: UpdateCommandOptions): Promise { @@ -1203,6 +1257,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const invocationCwd = tryResolveInvocationCwd(); const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1"; const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim(); + const postCoreRequestedChannelInput = + process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? ""; const timeoutMs = parseTimeoutMsOrExit(opts.timeout); const shouldRestart = opts.restart !== false; @@ -1223,10 +1279,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } + const postCoreRequestedChannel = postCoreRequestedChannelInput + ? normalizeUpdateChannel(postCoreRequestedChannelInput) + : null; + if (postCoreRequestedChannelInput && !postCoreRequestedChannel) { + defaultRuntime.error("Invalid post-core requested update channel context."); + defaultRuntime.exit(1); + return; + } + + const postCoreConfigSnapshot = await persistRequestedUpdateChannel({ + configSnapshot: await readConfigFileSnapshot(), + requestedChannel: postCoreRequestedChannel, + }); + const pluginUpdate = await runPostCorePluginUpdate({ root, channel: postCoreUpdateChannel, - configSnapshot: await readConfigFileSnapshot(), + configSnapshot: postCoreConfigSnapshot, opts, timeoutMs: updateStepTimeoutMs, }); @@ -1567,46 +1637,45 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } + const shouldResumePostCoreInFreshProcess = shouldResumePostCoreUpdateInFreshProcess({ + result, + downgradeRisk, + }); + let postUpdateConfigSnapshot = configSnapshot; - if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) { - const next = { - ...configSnapshot.sourceConfig, - update: { - ...configSnapshot.sourceConfig.update, - channel: requestedChannel, - }, - }; - await replaceConfigFile({ - nextConfig: next, - baseHash: configSnapshot.hash, + if (!shouldResumePostCoreInFreshProcess) { + postUpdateConfigSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, }); - postUpdateConfigSnapshot = { - ...configSnapshot, - hash: undefined, - parsed: next, - sourceConfig: asResolvedSourceConfig(next), - resolved: asResolvedSourceConfig(next), - runtimeConfig: asRuntimeConfig(next), - config: asRuntimeConfig(next), - }; - if (!opts.json) { - defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); - } + } + if ( + requestedChannel && + configSnapshot.valid && + requestedChannel !== storedChannel && + !shouldResumePostCoreInFreshProcess && + !opts.json + ) { + defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); + } else if ( + requestedChannel && + configSnapshot.valid && + requestedChannel !== storedChannel && + shouldResumePostCoreInFreshProcess && + !opts.json + ) { + defaultRuntime.log(theme.muted(`Update channel will be set to ${requestedChannel}.`)); } const postUpdateRoot = result.root ?? root; let postCorePluginUpdate: PostCorePluginUpdateResult | undefined; let pluginsUpdatedInFreshProcess = false; - if ( - shouldResumePostCoreUpdateInFreshProcess({ - result, - downgradeRisk, - }) - ) { + if (shouldResumePostCoreInFreshProcess) { const freshProcessResult = await continuePostCoreUpdateInFreshProcess({ root: postUpdateRoot, channel, + requestedChannel, opts, }); pluginsUpdatedInFreshProcess = freshProcessResult.resumed; @@ -1614,6 +1683,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } if (!pluginsUpdatedInFreshProcess) { + if (shouldResumePostCoreInFreshProcess) { + postUpdateConfigSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); + } postCorePluginUpdate = await runPostCorePluginUpdate({ root: postUpdateRoot, channel,