diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c04e1cc722c..7fe6b9debadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9dbbfc8a3336..3a0cfd6cec62 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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, diff --git a/src/cli/update-cli/update-command-fresh-doctor.ts b/src/cli/update-cli/update-command-fresh-doctor.ts index 7d7d9cbec252..60c22abc0b84 100644 --- a/src/cli/update-cli/update-command-fresh-doctor.ts +++ b/src/cli/update-cli/update-command-fresh-doctor.ts @@ -20,7 +20,9 @@ import { stripGatewayServiceMarkerEnv, } from "./update-command-service.js"; -export function withUpdateFinalizationEnv(run: () => Promise): Promise { +type UpdateDoctorPhase = "pre-plugin" | "post-plugin"; + +export async function withPrePluginUpdateDoctorEnv(run: () => Promise): Promise { 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(run: () => Promise): Promise 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(run: () => Promise): Promise } else { process.env[UPDATE_POST_CORE_CONVERGENCE_ENV] = previousPostCoreConvergence; } - }); + } } async function withNormalConfigValidation(run: () => Promise): Promise { @@ -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)); } diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index bf2fa0a245f8..02f81f3781ba 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -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, diff --git a/src/cli/update-cli/update-command-resume.ts b/src/cli/update-cli/update-command-resume.ts index c856daed1e2f..44d5d8fe260a 100644 --- a/src/cli/update-cli/update-command-resume.ts +++ b/src/cli/update-cli/update-command-resume.ts @@ -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,