Skip to content

Commit 180033e

Browse files
committed
fix(update): resume git post-update in updated process
1 parent 43da089 commit 180033e

2 files changed

Lines changed: 192 additions & 35 deletions

File tree

src/cli/update-cli.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,36 @@ describe("update-cli", () => {
602602
expect(runDaemonRestart).not.toHaveBeenCalled();
603603
});
604604

605+
it("respawns into the updated git root before requested channel persistence", async () => {
606+
const { entrypoints } = setupUpdatedRootRefresh({
607+
gatewayUpdateImpl: async (root) =>
608+
makeOkUpdateResult({
609+
mode: "git",
610+
root,
611+
before: { sha: "old-sha", version: "2026.4.26" },
612+
after: { sha: "new-sha", version: "2026.4.27" },
613+
}),
614+
});
615+
616+
await updateCommand({ channel: "dev", yes: true, restart: false });
617+
618+
expect(spawn).toHaveBeenCalledWith(
619+
expect.stringMatching(/node/),
620+
[entrypoints[0], "update", "--no-restart", "--yes"],
621+
expect.objectContaining({
622+
stdio: "inherit",
623+
env: expect.objectContaining({
624+
OPENCLAW_UPDATE_POST_CORE: "1",
625+
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
626+
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
627+
}),
628+
}),
629+
);
630+
expect(replaceConfigFile).not.toHaveBeenCalled();
631+
expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
632+
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
633+
});
634+
605635
it("keeps downgrade post-update work in the current process", async () => {
606636
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
607637
setupUpdatedRootRefresh({
@@ -686,6 +716,47 @@ describe("update-cli", () => {
686716
expect(spawn).not.toHaveBeenCalled();
687717
});
688718

719+
it("post-core resume mode persists the requested update channel with the updated process", async () => {
720+
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
721+
...baseSnapshot,
722+
parsed: { update: { channel: "stable" } },
723+
resolved: { update: { channel: "stable" } } as OpenClawConfig,
724+
sourceConfig: { update: { channel: "stable" } } as OpenClawConfig,
725+
runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig,
726+
config: { update: { channel: "stable" } } as OpenClawConfig,
727+
hash: "stable-hash",
728+
});
729+
730+
await withEnvAsync(
731+
{
732+
OPENCLAW_UPDATE_POST_CORE: "1",
733+
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
734+
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
735+
},
736+
async () => {
737+
await updateCommand({ restart: false });
738+
},
739+
);
740+
741+
expect(runGatewayUpdate).not.toHaveBeenCalled();
742+
expect(replaceConfigFile).toHaveBeenCalledWith({
743+
nextConfig: {
744+
update: {
745+
channel: "dev",
746+
},
747+
},
748+
baseHash: "stable-hash",
749+
});
750+
expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith(
751+
expect.objectContaining({
752+
channel: "dev",
753+
config: expect.objectContaining({
754+
update: expect.objectContaining({ channel: "dev" }),
755+
}),
756+
}),
757+
);
758+
});
759+
689760
it("passes the update timeout budget into post-core plugin updates", async () => {
690761
await withEnvAsync(
691762
{
@@ -1538,7 +1609,18 @@ describe("update-cli", () => {
15381609
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"],
15391610
expect.any(Object),
15401611
);
1541-
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
1612+
expect(spawn).toHaveBeenCalledWith(
1613+
expect.stringMatching(/node/),
1614+
[entryPath, "update", "--no-restart", "--yes"],
1615+
expect.objectContaining({
1616+
stdio: "inherit",
1617+
env: expect.objectContaining({
1618+
OPENCLAW_UPDATE_POST_CORE: "1",
1619+
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
1620+
}),
1621+
}),
1622+
);
1623+
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
15421624
expect(
15431625
vi
15441626
.mocked(defaultRuntime.log)

src/cli/update-cli/update-command.ts

Lines changed: 109 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import { defaultRuntime } from "../../runtime.js";
5454
import { normalizeOptionalString } from "../../shared/string-coerce.js";
5555
import { stylePromptMessage } from "../../terminal/prompt-style.js";
5656
import { theme } from "../../terminal/theme.js";
57-
import { pathExists } from "../../utils.js";
5857
import { replaceCliName, resolveCliName } from "../cli-name.js";
5958
import { formatCliCommand } from "../command-format.js";
6059
import { installCompletion } from "../completion-runtime.js";
@@ -93,6 +92,7 @@ const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
9392
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
9493
const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
9594
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
95+
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
9696
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
9797
const SERVICE_REFRESH_PATH_ENV_KEYS = [
9898
"OPENCLAW_HOME",
@@ -1093,6 +1093,40 @@ async function runPostCorePluginUpdate(params: {
10931093
});
10941094
}
10951095

1096+
async function persistRequestedUpdateChannel(params: {
1097+
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
1098+
requestedChannel: "stable" | "beta" | "dev" | null;
1099+
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
1100+
if (!params.requestedChannel || !params.configSnapshot.valid) {
1101+
return params.configSnapshot;
1102+
}
1103+
const storedChannel = normalizeUpdateChannel(params.configSnapshot.config.update?.channel);
1104+
if (params.requestedChannel === storedChannel) {
1105+
return params.configSnapshot;
1106+
}
1107+
1108+
const next = {
1109+
...params.configSnapshot.sourceConfig,
1110+
update: {
1111+
...params.configSnapshot.sourceConfig.update,
1112+
channel: params.requestedChannel,
1113+
},
1114+
};
1115+
await replaceConfigFile({
1116+
nextConfig: next,
1117+
baseHash: params.configSnapshot.hash,
1118+
});
1119+
return {
1120+
...params.configSnapshot,
1121+
hash: undefined,
1122+
parsed: next,
1123+
sourceConfig: asResolvedSourceConfig(next),
1124+
resolved: asResolvedSourceConfig(next),
1125+
runtimeConfig: asRuntimeConfig(next),
1126+
config: asRuntimeConfig(next),
1127+
};
1128+
}
1129+
10961130
async function writePostCorePluginUpdateResultFile(
10971131
filePath: string | undefined,
10981132
result: PostCorePluginUpdateResult,
@@ -1125,10 +1159,11 @@ async function readPostCorePluginUpdateResultFile(
11251159
async function continuePostCoreUpdateInFreshProcess(params: {
11261160
root: string;
11271161
channel: "stable" | "beta" | "dev";
1162+
requestedChannel: "stable" | "beta" | "dev" | null;
11281163
opts: UpdateCommandOptions;
11291164
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
1130-
const entryPath = path.join(params.root, "dist", "entry.js");
1131-
if (!(await pathExists(entryPath))) {
1165+
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
1166+
if (!entryPath) {
11321167
return { resumed: false };
11331168
}
11341169

@@ -1158,6 +1193,9 @@ async function continuePostCoreUpdateInFreshProcess(params: {
11581193
...disableUpdatedPackageCompileCacheEnv(process.env),
11591194
[POST_CORE_UPDATE_ENV]: "1",
11601195
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
1196+
...(params.requestedChannel
1197+
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
1198+
: {}),
11611199
...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}),
11621200
},
11631201
});
@@ -1195,14 +1233,32 @@ function shouldResumePostCoreUpdateInFreshProcess(params: {
11951233
result: UpdateRunResult;
11961234
downgradeRisk: boolean;
11971235
}): boolean {
1198-
return isPackageManagerUpdateMode(params.result.mode) && !params.downgradeRisk;
1236+
if (params.downgradeRisk) {
1237+
return false;
1238+
}
1239+
if (isPackageManagerUpdateMode(params.result.mode)) {
1240+
return true;
1241+
}
1242+
if (params.result.mode !== "git") {
1243+
return false;
1244+
}
1245+
const beforeSha = normalizeOptionalString(params.result.before?.sha);
1246+
const afterSha = normalizeOptionalString(params.result.after?.sha);
1247+
if (beforeSha && afterSha && beforeSha !== afterSha) {
1248+
return true;
1249+
}
1250+
const beforeVersion = normalizeOptionalString(params.result.before?.version);
1251+
const afterVersion = normalizeOptionalString(params.result.after?.version);
1252+
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
11991253
}
12001254

12011255
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
12021256
suppressDeprecations();
12031257
const invocationCwd = tryResolveInvocationCwd();
12041258
const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1";
12051259
const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim();
1260+
const postCoreRequestedChannelInput =
1261+
process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? "";
12061262

12071263
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
12081264
const shouldRestart = opts.restart !== false;
@@ -1223,10 +1279,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
12231279
return;
12241280
}
12251281

1282+
const postCoreRequestedChannel = postCoreRequestedChannelInput
1283+
? normalizeUpdateChannel(postCoreRequestedChannelInput)
1284+
: null;
1285+
if (postCoreRequestedChannelInput && !postCoreRequestedChannel) {
1286+
defaultRuntime.error("Invalid post-core requested update channel context.");
1287+
defaultRuntime.exit(1);
1288+
return;
1289+
}
1290+
1291+
const postCoreConfigSnapshot = await persistRequestedUpdateChannel({
1292+
configSnapshot: await readConfigFileSnapshot(),
1293+
requestedChannel: postCoreRequestedChannel,
1294+
});
1295+
12261296
const pluginUpdate = await runPostCorePluginUpdate({
12271297
root,
12281298
channel: postCoreUpdateChannel,
1229-
configSnapshot: await readConfigFileSnapshot(),
1299+
configSnapshot: postCoreConfigSnapshot,
12301300
opts,
12311301
timeoutMs: updateStepTimeoutMs,
12321302
});
@@ -1567,53 +1637,58 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
15671637
return;
15681638
}
15691639

1640+
const shouldResumePostCoreInFreshProcess = shouldResumePostCoreUpdateInFreshProcess({
1641+
result,
1642+
downgradeRisk,
1643+
});
1644+
15701645
let postUpdateConfigSnapshot = configSnapshot;
1571-
if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) {
1572-
const next = {
1573-
...configSnapshot.sourceConfig,
1574-
update: {
1575-
...configSnapshot.sourceConfig.update,
1576-
channel: requestedChannel,
1577-
},
1578-
};
1579-
await replaceConfigFile({
1580-
nextConfig: next,
1581-
baseHash: configSnapshot.hash,
1646+
if (!shouldResumePostCoreInFreshProcess) {
1647+
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
1648+
configSnapshot,
1649+
requestedChannel,
15821650
});
1583-
postUpdateConfigSnapshot = {
1584-
...configSnapshot,
1585-
hash: undefined,
1586-
parsed: next,
1587-
sourceConfig: asResolvedSourceConfig(next),
1588-
resolved: asResolvedSourceConfig(next),
1589-
runtimeConfig: asRuntimeConfig(next),
1590-
config: asRuntimeConfig(next),
1591-
};
1592-
if (!opts.json) {
1593-
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
1594-
}
1651+
}
1652+
if (
1653+
requestedChannel &&
1654+
configSnapshot.valid &&
1655+
requestedChannel !== storedChannel &&
1656+
!shouldResumePostCoreInFreshProcess &&
1657+
!opts.json
1658+
) {
1659+
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
1660+
} else if (
1661+
requestedChannel &&
1662+
configSnapshot.valid &&
1663+
requestedChannel !== storedChannel &&
1664+
shouldResumePostCoreInFreshProcess &&
1665+
!opts.json
1666+
) {
1667+
defaultRuntime.log(theme.muted(`Update channel will be set to ${requestedChannel}.`));
15951668
}
15961669

15971670
const postUpdateRoot = result.root ?? root;
15981671

15991672
let postCorePluginUpdate: PostCorePluginUpdateResult | undefined;
16001673
let pluginsUpdatedInFreshProcess = false;
1601-
if (
1602-
shouldResumePostCoreUpdateInFreshProcess({
1603-
result,
1604-
downgradeRisk,
1605-
})
1606-
) {
1674+
if (shouldResumePostCoreInFreshProcess) {
16071675
const freshProcessResult = await continuePostCoreUpdateInFreshProcess({
16081676
root: postUpdateRoot,
16091677
channel,
1678+
requestedChannel,
16101679
opts,
16111680
});
16121681
pluginsUpdatedInFreshProcess = freshProcessResult.resumed;
16131682
postCorePluginUpdate = freshProcessResult.pluginUpdate;
16141683
}
16151684

16161685
if (!pluginsUpdatedInFreshProcess) {
1686+
if (shouldResumePostCoreInFreshProcess) {
1687+
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
1688+
configSnapshot,
1689+
requestedChannel,
1690+
});
1691+
}
16171692
postCorePluginUpdate = await runPostCorePluginUpdate({
16181693
root: postUpdateRoot,
16191694
channel,

0 commit comments

Comments
 (0)