fix(update): resume git post-update in updated process

This commit is contained in:
Peter Steinberger
2026-04-29 03:38:50 +01:00
parent 43da089790
commit 180033eeae
2 changed files with 192 additions and 35 deletions

View File

@@ -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)

View File

@@ -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<ReturnType<typeof readConfigFileSnapshot>>;
requestedChannel: "stable" | "beta" | "dev" | null;
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
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<void> {
@@ -1203,6 +1257,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
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<void> {
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<void> {
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<void> {
}
if (!pluginsUpdatedInFreshProcess) {
if (shouldResumePostCoreInFreshProcess) {
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
postCorePluginUpdate = await runPostCorePluginUpdate({
root: postUpdateRoot,
channel,