fix(update): avoid duplicate configured plugin installs (#122161)

Make updater doctor phases explicit so only post-plugin finalization marks post-core convergence, and strip ambient convergence state from fresh child environments.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent Koc
2026-08-12 01:56:40 +08:00
committed by GitHub
parent d9766c3d57
commit 0e52f4c4ce
5 changed files with 42 additions and 14 deletions
+1
View File
@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Updater plugin convergence:** keep pre-plugin doctor passes from installing configured plugins before the updater's plugin sweep, while preserving the final post-plugin migration pass and preventing ambient update-phase state from leaking into fresh doctor processes.
- **Control UI browser tab identity:** keep selected tab styling, accessibility, focus, address, and page snapshot aligned across in-place navigation and tab reordering. Fixes #120745. Thanks @shakkernerd.
- **Control UI staged attachments:** preserve unsent images, files, pasted images, and large pasted text across same-tab route and narrow split-pane remounts while keeping pane close, mismatched pane/session/Gateway remounts, application shutdown, and hard reload as cleanup boundaries. Fixes #121519. Thanks @shakkernerd.
- **Control UI browser annotations:** keep marked screenshots and generated page context together in structured composer cards, preserve user-written drafts when annotations are removed or replaced, retain complete unsent annotation packages across same-tab route and active split-pane remounts, and offer bounded Undo without restoring removed context into another session. Fixes #120744. Thanks @shakkernerd.
+20 -5
View File
@@ -2501,7 +2501,7 @@ describe("update-cli", () => {
});
it("post-core resume mode skips the core update and only runs post-update tasks", async () => {
await runPostCoreCommand({ restart: false });
await runPostCoreCommand({ restart: false }, { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1" });
expect(runGatewayUpdate).not.toHaveBeenCalled();
const installCall = (
@@ -2524,6 +2524,21 @@ describe("update-cli", () => {
([, args]) => args[0] === FRESH_POST_UPDATE_ENTRYPOINT && args[1] === "doctor",
);
expect(freshDoctorCall).toBeDefined();
expect(freshDoctorCall?.[2]).toMatchObject({
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
},
});
expect(
(freshDoctorCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env
?.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE,
).toBeUndefined();
expect(
(freshDoctorCall?.[2] as { baseEnv?: NodeJS.ProcessEnv } | undefined)?.baseEnv
?.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE,
).toBeUndefined();
expect(vi.mocked(runExec).mock.invocationCallOrder[0] ?? 0).toBeLessThan(
syncPluginsForUpdateChannel.mock.invocationCallOrder[0] ?? 0,
);
@@ -7248,13 +7263,13 @@ describe("update-cli", () => {
});
});
it("updateFinalizeCommand runs doctor and plugin convergence with full update env", async () => {
it("updateFinalizeCommand defers plugin installation during pre-plugin doctor", async () => {
await withEnvAsync(
{
OPENCLAW_UPDATE_IN_PROGRESS: undefined,
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: undefined,
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: undefined,
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: undefined,
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
},
async () => {
let doctorEnv: NodeJS.ProcessEnv | undefined;
@@ -7274,11 +7289,11 @@ describe("update-cli", () => {
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE).toBeUndefined();
expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined();
expect(process.env.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBeUndefined();
expect(process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBeUndefined();
expect(process.env.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE).toBeUndefined();
expect(process.env.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE).toBe("1");
expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, {
nonInteractive: true,
repair: true,
@@ -20,7 +20,9 @@ import {
stripGatewayServiceMarkerEnv,
} from "./update-command-service.js";
export function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T> {
type UpdateDoctorPhase = "pre-plugin" | "post-plugin";
export async function withPrePluginUpdateDoctorEnv<T>(run: () => Promise<T>): Promise<T> {
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
const previousDeferConfiguredPluginInstallRepair =
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
@@ -30,8 +32,10 @@ export function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T>
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
process.env[UPDATE_POST_CORE_CONVERGENCE_ENV] = "1";
return run().finally(() => {
delete process.env[UPDATE_POST_CORE_CONVERGENCE_ENV];
try {
return await run();
} finally {
if (previousUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
@@ -54,7 +58,7 @@ export function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T>
} else {
process.env[UPDATE_POST_CORE_CONVERGENCE_ENV] = previousPostCoreConvergence;
}
});
}
}
async function withNormalConfigValidation<T>(run: () => Promise<T>): Promise<T> {
@@ -91,6 +95,7 @@ function createPostPluginDoctorExecutionFailure(
}
export async function runUpdateFinalizationDoctorInFreshProcess(params: {
phase: UpdateDoctorPhase;
root: string;
yes: boolean;
json: boolean;
@@ -110,17 +115,19 @@ export async function runUpdateFinalizationDoctorInFreshProcess(params: {
"--no-workspace-suggestions",
...(params.yes ? ["--yes"] : []),
];
const baseEnv = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env));
delete baseEnv[UPDATE_POST_CORE_CONVERGENCE_ENV];
const result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, {
cwd: params.root,
timeoutMs: params.timeoutMs,
maxBuffer: 4 * 1024 * 1024,
logOutput: false,
baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
baseEnv,
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
...(params.phase === "post-plugin" ? { [UPDATE_POST_CORE_CONVERGENCE_ENV]: "1" } : {}),
},
});
if (!params.json) {
@@ -186,7 +193,11 @@ async function applyFreshPostPluginDoctor(params: {
}
let pluginUpdate = params.pluginUpdate;
try {
await runUpdateFinalizationDoctorInFreshProcess({ ...params, entryPath });
await runUpdateFinalizationDoctorInFreshProcess({
...params,
entryPath,
phase: "post-plugin",
});
} catch (err) {
pluginUpdate = createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err));
}
@@ -76,7 +76,7 @@ import {
} from "./update-command-config.js";
import {
completePostCorePluginUpdate,
withUpdateFinalizationEnv,
withPrePluginUpdateDoctorEnv,
} from "./update-command-fresh-doctor.js";
import {
updatePluginsAfterCoreUpdate,
@@ -207,7 +207,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
}
const completedPluginUpdate = await withPluginLifecycleLease({}, async () => {
const initialPluginUpdate = await withUpdateFinalizationEnv(async () => {
const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => {
await createUpdateConfigSnapshot();
await doctorCommand(defaultRuntime, {
nonInteractive: true,
@@ -77,6 +77,7 @@ async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams):
});
await createUpdateConfigSnapshot();
await runUpdateFinalizationDoctorInFreshProcess({
phase: "pre-plugin",
root: params.root,
yes: params.opts.yes === true,
json: params.opts.json === true,