diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6803269d9..3d1d837e83c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys. - Doctor: warn when on-disk agent directories still exist under `~/.openclaw/agents//agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana. - Telegram: route approval button callback queries onto a separate sequentializer lane so plugin approval clicks can resolve immediately instead of deadlocking behind the blocked agent turn. (#64979) Thanks @nk3750. +- CLI/update: respawn tracked plugin refresh from the updated entrypoint after package self-updates so `openclaw update` stops failing on stale hashed `dist/install.runtime-*.js` chunk imports. (#65368) Thanks @songshikang0111 and @vincentkoc. - Plugins/memory-core dreaming: keep bundled `memory-core` loaded alongside an explicit external memory slot owner only when that owner enables dreaming, while preserving `plugins.slots.memory = "none"` disable semantics. (#65411) Thanks @pradeep7127 and @vincentkoc. - Agents/Anthropic replay: preserve immutable signed-thinking replay safety across stored and live reruns, keep non-thinking embedded `tool_result` user blocks intact, and drop conflicting preserved tool IDs before validation so retries stop degrading into omitted tool calls. (#65126) Thanks @shakkernerd. - Telegram/direct sessions: keep commentary-only assistant fallback payloads out of visible direct delivery, so Codex planning chatter cannot leak into Telegram DMs when a run has no `final_answer` text. (#65112) Thanks @vincentkoc. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 418a12bdc0d..a1d2b3eaf51 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -30,6 +31,7 @@ const pathExists = vi.fn(); const syncPluginsForUpdateChannel = vi.fn(); const updateNpmInstalledPlugins = vi.fn(); const nodeVersionSatisfiesEngine = vi.fn(); +const spawn = vi.fn(); const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("@clack/prompts", () => ({ @@ -112,6 +114,7 @@ vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { ...actual, + spawn, spawnSync: vi.fn(() => ({ pid: 0, output: [], @@ -346,6 +349,15 @@ describe("update-cli", () => { beforeEach(() => { vi.clearAllMocks(); resetRuntimeCapture(); + spawn.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { + once: EventEmitter["once"]; + }; + queueMicrotask(() => { + child.emit("exit", 0, null); + }); + return child; + }); vi.mocked(defaultRuntime.exit).mockImplementation(() => {}); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); @@ -444,6 +456,68 @@ describe("update-cli", () => { setStdoutTty(false); }); + it("respawns into the updated package root before running post-update tasks", async () => { + const { entryPath } = setupUpdatedRootRefresh(); + + await updateCommand({ yes: true }); + + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/node/), + [entryPath, "update", "--yes"], + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev", + }), + }), + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + }); + + it("fails the update when the fresh process exits non-zero", async () => { + setupUpdatedRootRefresh(); + spawn.mockImplementationOnce(() => { + const child = new EventEmitter() as EventEmitter & { + once: EventEmitter["once"]; + }; + queueMicrotask(() => { + child.emit("exit", 2, null); + }); + return child; + }); + + await expect(updateCommand({ yes: true })).rejects.toThrow( + "post-update process exited with code 2", + ); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(2); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + }); + + it("post-core resume mode skips the core update and only runs post-update tasks", async () => { + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE: "1", + OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable", + }, + async () => { + await updateCommand({ restart: false }); + }, + ); + + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).not.toHaveBeenCalledWith( + ["npm", "i", "-g", expect.any(String)], + expect.anything(), + ); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1); + expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1); + expect(spawn).not.toHaveBeenCalled(); + }); + it.each([ { name: "preview mode", diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index ddfb1e6c47c..9d4b3640328 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import path from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import { @@ -75,6 +76,8 @@ import { suppressDeprecations } from "./suppress-deprecations.js"; const CLI_NAME = resolveCliName(); const SERVICE_REFRESH_TIMEOUT_MS = 60_000; +const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; +const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", @@ -108,6 +111,10 @@ function pickUpdateQuip(): string { return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete."; } +function isPackageManagerUpdateMode(mode: UpdateRunResult["mode"]): mode is "npm" | "pnpm" | "bun" { + return mode === "npm" || mode === "pnpm" || mode === "bun"; +} + function resolveGatewayInstallEntrypointCandidates(root?: string): string[] { if (!root) { return []; @@ -759,9 +766,76 @@ async function maybeRestartService(params: { } } +async function runPostCorePluginUpdate(params: { + root: string; + channel: "stable" | "beta" | "dev"; + configSnapshot: Awaited>; + opts: UpdateCommandOptions; +}): Promise { + await updatePluginsAfterCoreUpdate({ + root: params.root, + channel: params.channel, + configSnapshot: params.configSnapshot, + opts: params.opts, + }); +} + +async function continuePostCoreUpdateInFreshProcess(params: { + root: string; + channel: "stable" | "beta" | "dev"; + root: string; + channel: "stable" | "beta" | "dev"; + opts: UpdateCommandOptions; + opts: UpdateCommandOptions; +}): Promise { + const entryPath = path.join(params.root, "dist", "entry.js"); + if (!(await pathExists(entryPath))) { + return false; + } + + const argv = [entryPath, "update"]; + if (params.opts.json) { + argv.push("--json"); + } + if (params.opts.restart === false) { + argv.push("--no-restart"); + } + if (params.opts.yes) { + argv.push("--yes"); + } + + const child = spawn(resolveNodeRunner(), argv, { + stdio: "inherit", + env: { + ...process.env, + [POST_CORE_UPDATE_ENV]: "1", + [POST_CORE_UPDATE_CHANNEL_ENV]: params.channel, + }, + }); + + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal) { + reject(new Error(`post-update process terminated by signal ${signal}`)); + return; + } + resolve(code ?? 1); + }); + }); + + if (exitCode !== 0) { + defaultRuntime.exit(exitCode); + throw new Error(`post-update process exited with code ${exitCode}`); + } + return true; +} + export async function updateCommand(opts: UpdateCommandOptions): Promise { suppressDeprecations(); const invocationCwd = tryResolveInvocationCwd(); + const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1"; + const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim(); const timeoutMs = parseTimeoutMsOrExit(opts.timeout); const shouldRestart = opts.restart !== false; @@ -770,6 +844,26 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } const root = await resolveUpdateRoot(); + if (postCoreUpdateResume) { + if ( + postCoreUpdateChannel !== "stable" && + postCoreUpdateChannel !== "beta" && + postCoreUpdateChannel !== "dev" + ) { + defaultRuntime.error("Missing post-core update channel context."); + defaultRuntime.exit(1); + return; + } + + await runPostCorePluginUpdate({ + root, + channel: postCoreUpdateChannel, + configSnapshot: await readConfigFileSnapshot(), + opts, + }); + return; + } + const updateStatus = await checkUpdateStatus({ root, timeoutMs: timeoutMs ?? 3500, @@ -960,24 +1054,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const { progress, stop } = createUpdateProgress(showProgress); const startedAt = Date.now(); - let restartScriptPath: string | null = null; - let refreshGatewayServiceEnv = false; - const gatewayPort = resolveGatewayPort( - configSnapshot.valid ? configSnapshot.config : undefined, - process.env, - ); - if (shouldRestart) { - try { - const loaded = await resolveGatewayService().isLoaded({ env: process.env }); - if (loaded) { - restartScriptPath = await prepareRestartScript(process.env, gatewayPort); - refreshGatewayServiceEnv = true; - } - } catch { - // Ignore errors during pre-check; fallback to standard restart - } - } - const result = updateInstallKind === "package" ? await runPackageInstallUpdate({ @@ -1089,11 +1165,16 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const postUpdateRoot = result.root ?? root; - // A package -> git switch still runs inside the pre-update CLI process. - // Any follow-up work that re-enters the CLI can then compare new bundled - // plugin minima against the old host version and fail even though the - // install itself succeeded. Leave the switched checkout alone and let the - // new git install handle follow-up commands in a fresh process. + let pluginsUpdatedInFreshProcess = false; + if (isPackageManagerUpdateMode(result.mode)) { + pluginsUpdatedInFreshProcess = await continuePostCoreUpdateInFreshProcess({ + root: postUpdateRoot, + channel, + resultMode: result.mode, + opts, + }); + } + const deferOldProcessPostUpdateWork = switchToGit && result.mode === "git"; if (deferOldProcessPostUpdateWork) { if (!opts.json) { @@ -1103,8 +1184,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { ), ); } - } else { - await updatePluginsAfterCoreUpdate({ + } else if (!pluginsUpdatedInFreshProcess) { + await runPostCorePluginUpdate({ root: postUpdateRoot, channel, configSnapshot: postUpdateConfigSnapshot, @@ -1112,6 +1193,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { }); } + let restartScriptPath: string | null = null; + let refreshGatewayServiceEnv = false; + const gatewayPort = resolveGatewayPort( + postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined, + process.env, + ); + if (shouldRestart) { + try { + const loaded = await resolveGatewayService().isLoaded({ env: process.env }); + if (loaded) { + restartScriptPath = await prepareRestartScript(process.env, gatewayPort); + refreshGatewayServiceEnv = true; + } + } catch { + // Ignore errors during pre-check; fallback to standard restart + } + } + if (deferOldProcessPostUpdateWork) { if (!opts.json) { defaultRuntime.log(