From e3c40efeb09fd1c05e78f1ddfa73cd01d0f01320 Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Mon, 6 Jul 2026 19:57:03 -0700 Subject: [PATCH] feat(update): notify extended-stable availability (#100438) * docs(update): clarify extended-stable notifications * feat(update): notify extended-stable availability * fix(update): isolate notification state * fix(docs): align update channel table * fix(update): refresh after channel switch * fix(update): satisfy notification checks * test(update): prove loopback notification path * test(update): fix loopback server lifecycle * fix(update): refresh extended stable without cache * fix(update): retain extended stable throttle marker --- docs/cli/update.md | 8 +- docs/gateway/configuration-reference.md | 8 +- docs/install/development-channels.md | 5 +- docs/install/updating.md | 21 +- src/config/schema.help.ts | 8 +- src/infra/update-startup.integration.test.ts | 109 +++++ src/infra/update-startup.test.ts | 471 +++++++++++++++++-- src/infra/update-startup.ts | 140 ++++-- 8 files changed, 687 insertions(+), 83 deletions(-) create mode 100644 src/infra/update-startup.integration.test.ts diff --git a/docs/cli/update.md b/docs/cli/update.md index 70e9e51ef1f..05058fe3c6d 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -155,9 +155,11 @@ from outside the Gateway process tree. If the handoff is unavailable, `update.run` returns a structured response with the safe shell command to run manually. -Extended-stable is deliberately excluded from startup checks and background -auto-update scheduling. Explicit foreground updates, bare foreground updates -with stored `update.channel: "extended-stable"`, on-demand status, and managed +Stored extended-stable selections receive read-only startup and 24-hour update +hints when `update.checkOnStart` is enabled. These checks never apply an update, +start a handoff, restart the Gateway, use stable delay/jitter, or use beta +polling cadence. Explicit foreground updates, bare foreground updates with +stored `update.channel: "extended-stable"`, on-demand status, and their managed Gateway handoff remain supported. When a local managed Gateway service is installed and restart is enabled, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 4f5f36c8c39..2b41702024a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1212,12 +1212,12 @@ rows. Query them with [`openclaw audit`](/cli/audit) or the } ``` -- `channel`: release channel - `"stable"`, `"extended-stable"`, `"beta"`, or `"dev"`. Extended-stable is a package-only, foreground/on-demand channel; it is skipped by startup checks and background auto-update. -- `checkOnStart`: check for npm updates when the gateway starts (default: `true`). -- `auto.enabled`: enable background auto-update for package installs (default: `false`). +- `channel`: release channel - `"stable"`, `"extended-stable"`, `"beta"`, or `"dev"`. Extended-stable is package-only: foreground commands own installation, while the Gateway may emit read-only update hints. +- `checkOnStart`: check for npm updates when the gateway starts (default: `true`). Stored extended-stable selections use the same read-only hint and 24-hour hint schedule. +- `auto.enabled`: enable background auto-update for stable and beta package installs (default: `false`). Extended-stable never applies automatically. - `auto.stableDelayHours`: minimum delay in hours before stable-channel auto-apply (default: `6`; max: `168`). - `auto.stableJitterHours`: extra stable-channel rollout spread window in hours (default: `12`; max: `168`). -- `auto.betaCheckIntervalHours`: how often beta-channel checks run in hours (default: `1`; max: `24`). +- `auto.betaCheckIntervalHours`: how often beta-channel checks run in hours (default: `1`; max: `24`). Stable delay/jitter and beta polling settings do not apply to extended-stable. --- diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index a7d4891dc61..ef86ddd7b7e 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -12,8 +12,9 @@ OpenClaw ships four update channels: - **stable**: npm dist-tag `latest`. Recommended for most users. - **extended-stable**: npm dist-tag `extended-stable`. A net-new, trailing - supported-month package channel. It is package-only and foreground-only in - this release. + supported-month package channel. It is package-only, and installation is + foreground-only. A stored selection receives read-only update hints when + `update.checkOnStart` is enabled, but never applies automatically. - **beta**: npm dist-tag `beta`. Falls back to `latest` when `beta` is missing or older than the current stable release. - **dev**: moving head of `main` (git). npm dist-tag `dev` when published. `main` diff --git a/docs/install/updating.md b/docs/install/updating.md index b1f7853eb76..1f65e3625cf 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -34,11 +34,14 @@ when the beta tag is missing or its version is older than the latest stable release. Use `--tag beta` for a one-off package update pinned to the raw npm beta dist-tag instead. -`--channel extended-stable` is package-only and foreground-only. OpenClaw reads -the public npm `extended-stable` selector, verifies the selected exact package, -and installs that exact version. Missing or inconsistent registry data fails -closed; it never falls back to `latest`. If the selected version is older than -the installed version, the normal downgrade confirmation still applies. +`--channel extended-stable` is package-only, and installation remains +foreground-only. OpenClaw reads the public npm `extended-stable` selector, +verifies the selected exact package, and installs that exact version. Missing +or inconsistent registry data fails closed; it never falls back to `latest`. +If the selected version is older than the installed version, the normal +downgrade confirmation still applies. The CLI persists the channel after a +successful core update; a direct `npm install -g openclaw@extended-stable` +does not update `update.channel`. After the core swap, eligible official npm plugins with bare/default or `latest` intent converge to that exact core version. Exact pins and explicit non-`latest` tags, third-party plugins, and non-npm sources remain unchanged. @@ -213,12 +216,14 @@ Off by default. Enable it in `~/.openclaw/openclaw.json`: | Channel | Behavior | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `stable` | Waits `stableDelayHours` (default: 6), then applies with deterministic jitter across `stableJitterHours` (default: 12) for a spread rollout. | -| `extended-stable` | No startup check or automatic apply. Use `openclaw update` or `openclaw update status` manually. | +| `extended-stable` | Checks for a read-only update hint on startup and every 24 hours when `checkOnStart` is enabled. Never applies automatically. | | `beta` | Checks every `betaCheckIntervalHours` (default: 1) and applies immediately. | | `dev` | No automatic apply. Use `openclaw update` manually. | -The gateway also logs an update hint on startup (disable with `update.checkOnStart: false`). -Stored extended-stable selections skip startup and background resolution entirely. +The gateway also logs an update hint on startup (disable with +`update.checkOnStart: false`). Stored extended-stable selections use this +read-only hint path and the existing 24-hour hint interval, but never invoke +automatic installation, handoff, restart, stable delay/jitter, or beta polling. For downgrade or incident recovery, set `OPENCLAW_NO_AUTO_UPDATE=1` in the gateway environment to block automatic applies even when `update.auto.enabled` is configured. Startup update hints can still run unless `update.checkOnStart` is also disabled. Package-manager updates requested through the live Gateway control-plane diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 58a9af9d26c..b21820b5277 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -71,9 +71,11 @@ export const FIELD_HELP: Record = { update: "Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.", "update.channel": - 'Update channel for git + npm installs ("stable", "extended-stable", "beta", or "dev"). Extended-stable is package-only and foreground-only.', - "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", - "update.auto.enabled": "Enable background auto-update for package installs (default: false).", + 'Update channel for git + npm installs ("stable", "extended-stable", "beta", or "dev"). Extended-stable is package-only: installation is foreground-only, with optional read-only startup hints.', + "update.checkOnStart": + "Check for npm updates when the gateway starts, including read-only extended-stable hints (default: true).", + "update.auto.enabled": + "Enable background auto-update for stable and beta package installs; extended-stable never auto-applies (default: false).", "update.auto.stableDelayHours": "Minimum delay before stable-channel auto-apply starts (default: 6).", "update.auto.stableJitterHours": diff --git a/src/infra/update-startup.integration.test.ts b/src/infra/update-startup.integration.test.ts new file mode 100644 index 00000000000..e288531a5ee --- /dev/null +++ b/src/infra/update-startup.integration.test.ts @@ -0,0 +1,109 @@ +// Proves startup update discovery through the real extended-stable registry resolver. +import http from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; +import type { UpdateCheckResult } from "./update-check.js"; + +vi.mock("./openclaw-root.js", async () => { + const actual = await vi.importActual("./openclaw-root.js"); + return { + ...actual, + resolveOpenClawPackageRoot: vi.fn(async () => "/opt/openclaw"), + }; +}); + +vi.mock("./update-check.js", async () => { + const actual = await vi.importActual("./update-check.js"); + return { + ...actual, + checkUpdateStatus: vi.fn(async () => ({ + root: "/opt/openclaw", + installKind: "package", + packageManager: "npm", + }) satisfies UpdateCheckResult), + }; +}); + +vi.mock("../version.js", () => ({ + VERSION: "1.0.0", +})); + +describe("extended-stable startup update integration", () => { + let testState: OpenClawTestState; + let server: http.Server | undefined; + + beforeEach(async () => { + server = undefined; + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-update-startup-integration-", + env: { + NODE_ENV: "test", + NPM_CONFIG_REGISTRY: undefined, + OPENCLAW_UPDATE_PACKAGE_SPEC: undefined, + VITEST: undefined, + }, + }); + }); + + afterEach(async () => { + const activeServer = server; + if (activeServer) { + await new Promise((resolve, reject) => { + activeServer.close((error) => (error ? reject(error) : resolve())); + }); + } + closeOpenClawStateDatabaseForTest(); + await testState.cleanup(); + }); + + it("emits a read-only hint after verifying a newer exact loopback package", async () => { + const requests: string[] = []; + const registryServer = http.createServer((request, response) => { + requests.push(request.url ?? ""); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.0" })); + }); + server = registryServer; + await new Promise((resolve) => { + registryServer.listen(0, "127.0.0.1", resolve); + }); + const address = registryServer.address(); + if (!address || typeof address === "string") { + throw new Error("expected loopback registry address"); + } + process.env.OPENCLAW_UPDATE_PACKAGE_SPEC = "openclaw"; + process.env.NPM_CONFIG_REGISTRY = `http://127.0.0.1:${address.port}/`; + + const { runGatewayUpdateCheck, resetUpdateAvailableStateForTest } = + await import("./update-startup.js"); + resetUpdateAvailableStateForTest(); + const log = { info: vi.fn() }; + const onUpdateAvailableChange = vi.fn(); + const runAutoUpdate = vi.fn(); + + await runGatewayUpdateCheck({ + cfg: { update: { channel: "extended-stable", auto: { enabled: true } } }, + log, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + runAutoUpdate, + }); + + expect(requests).toEqual(["/openclaw/extended-stable", "/openclaw/2.0.0"]); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(log.info).toHaveBeenCalledWith( + "update available (extended-stable): v2.0.0 (current v1.0.0). Run: openclaw update", + ); + expect(runAutoUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index eb50473141f..ab2ed39a44d 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -337,6 +337,72 @@ describe("update-startup", () => { }; } + function createExtendedStableConfig(params?: { + checkOnStart?: boolean; + autoEnabled?: boolean; + }) { + return { + update: { + ...(params?.checkOnStart === false ? { checkOnStart: false } : {}), + channel: "extended-stable" as const, + ...(params?.autoEnabled ? { auto: { enabled: true } } : {}), + }, + }; + } + + async function runExtendedStableUpdateCheck(params?: { + cfg?: ReturnType; + log?: Parameters[0]["log"]; + onUpdateAvailableChange?: Parameters< + typeof runGatewayUpdateCheck + >[0]["onUpdateAvailableChange"]; + runAutoUpdate?: ReturnType; + isNixMode?: boolean; + }) { + const log = params?.log ?? { info: vi.fn() }; + await runGatewayUpdateCheck({ + cfg: params?.cfg ?? createExtendedStableConfig(), + log, + isNixMode: params?.isNixMode ?? false, + allowInTests: true, + ...(params?.onUpdateAvailableChange + ? { onUpdateAvailableChange: params.onUpdateAvailableChange } + : {}), + ...(params?.runAutoUpdate ? { runAutoUpdate: params.runAutoUpdate } : {}), + }); + } + + async function seedExtendedStableAvailability(params?: { + onUpdateAvailableChange?: Parameters< + typeof runGatewayUpdateCheck + >[0]["onUpdateAvailableChange"]; + }) { + mockPackageInstallStatus(); + mockNpmChannelTag("extended-stable", "2.0.0"); + await runExtendedStableUpdateCheck({ + onUpdateAvailableChange: params?.onUpdateAvailableChange, + }); + } + + function seedStableAutoRolloutState() { + writePersistedUpdateCheckState({ + ...readPersistedUpdateCheckState(), + autoInstallId: "stable-install-id", + autoFirstSeenVersion: "3.0.0", + autoFirstSeenTag: "latest", + autoFirstSeenAt: "2026-01-16T10:00:00.000Z", + }); + } + + function expectStableAutoRolloutStatePreserved() { + expect(readPersistedUpdateCheckState()).toMatchObject({ + autoInstallId: "stable-install-id", + autoFirstSeenVersion: "3.0.0", + autoFirstSeenTag: "latest", + autoFirstSeenAt: "2026-01-16T10:00:00.000Z", + }); + } + async function runAutoUpdateCheckWithDefaults(params: { cfg: { update?: Record }; runAutoUpdate?: ReturnType; @@ -422,35 +488,171 @@ describe("update-startup", () => { expect(parsed?.lastAvailableVersion).toBe("2.0.0"); }); - it("hydrates cached update from persisted state during throttle window", async () => { + it.each([ + { + channel: "stable" as const, + persistedTag: undefined, + expectedTag: "latest", + preflightsInstallKind: false, + }, + { + channel: "stable" as const, + persistedTag: "latest", + expectedTag: "latest", + preflightsInstallKind: false, + }, + { + channel: "beta" as const, + persistedTag: "beta", + expectedTag: "beta", + preflightsInstallKind: false, + }, + { + channel: "beta" as const, + persistedTag: "latest", + expectedTag: "latest", + preflightsInstallKind: false, + }, + { + channel: "extended-stable" as const, + persistedTag: "extended-stable", + expectedTag: "extended-stable", + preflightsInstallKind: true, + }, + { + channel: "dev" as const, + persistedTag: "dev", + expectedTag: "dev", + preflightsInstallKind: false, + }, + ])( + "hydrates $channel cached availability from its compatible $expectedTag tag", + async ({ channel, persistedTag, expectedTag, preflightsInstallKind }) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastAvailableVersion: "2.0.0", + lastAvailableTag: persistedTag, + }); + const onUpdateAvailableChange = vi.fn(); + + await runGatewayUpdateCheck({ + cfg: { update: { channel } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(preflightsInstallKind ? 1 : 0); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: expectedTag, + }); + expect(getUpdateAvailable()).toEqual({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: expectedTag, + }); + }, + ); + + it.each([ + { channel: "stable" as const, persistedTag: "beta" }, + { channel: "stable" as const, persistedTag: "extended-stable" }, + { channel: "beta" as const, persistedTag: undefined }, + { channel: "beta" as const, persistedTag: "extended-stable" }, + { channel: "dev" as const, persistedTag: "latest" }, + ])( + "suppresses $persistedTag persisted availability on the $channel channel", + async ({ channel, persistedTag }) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastAvailableVersion: "2.0.0", + lastAvailableTag: persistedTag, + }); + const onUpdateAvailableChange = vi.fn(); + + await runGatewayUpdateCheck({ + cfg: { update: { channel } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + }, + ); + + it.each(["latest", "beta"])( + "bypasses the shared throttle for mismatched %s availability on extended-stable", + async (persistedTag) => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastAvailableVersion: "2.0.0", + lastAvailableTag: persistedTag, + }); + mockPackageUpdateStatus("extended-stable", "2.0.0"); + const onUpdateAvailableChange = vi.fn(); + + await runExtendedStableUpdateCheck({ onUpdateAvailableChange }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ + channel: "extended-stable", + timeoutMs: 2500, + }); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + }, + ); + + it("bypasses a recent empty prior-channel check on extended-stable", async () => { writePersistedUpdateCheckState({ lastCheckedAt: new Date(Date.now()).toISOString(), - lastAvailableVersion: "2.0.0", - lastAvailableTag: "latest", }); + mockPackageUpdateStatus("extended-stable", "2.0.0"); - const onUpdateAvailableChange = vi.fn(); - await runGatewayUpdateCheck({ - cfg: { update: { channel: "stable" } }, - log: { info: vi.fn() }, - isNixMode: false, - allowInTests: true, - onUpdateAvailableChange, - }); + await runExtendedStableUpdateCheck(); - expect(vi.mocked(checkUpdateStatus)).not.toHaveBeenCalled(); - expect(onUpdateAvailableChange).toHaveBeenCalledWith({ - currentVersion: "1.0.0", - latestVersion: "2.0.0", - channel: "latest", + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ + channel: "extended-stable", + timeoutMs: 2500, }); expect(getUpdateAvailable()).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", - channel: "latest", + channel: "extended-stable", }); }); + it("honors the shared throttle after a recent extended-stable check marker", async () => { + writePersistedUpdateCheckState({ + lastCheckedAt: new Date(Date.now()).toISOString(), + lastAvailableVersion: "1.0.0", + lastAvailableTag: "extended-stable", + }); + mockPackageUpdateStatus("extended-stable", "2.0.0"); + + await runExtendedStableUpdateCheck(); + + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + }); + it("emits update change callback when update state clears", async () => { mockPackageInstallStatus(); vi.mocked(resolveNpmChannelTag) @@ -492,21 +694,68 @@ describe("update-startup", () => { await expectPathMissing(path.join(tempDir, "update-check.json")); }); - it("skips all startup and background work for extended-stable", async () => { + it("discovers and deduplicates an exact extended-stable update without auto-applying", async () => { + const onUpdateAvailableChange = vi.fn(); + const runAutoUpdate = createAutoUpdateSuccessMock(); + mockPackageUpdateStatus("extended-stable", "2.0.0"); + const log = { info: vi.fn() }; + + await runExtendedStableUpdateCheck({ + cfg: createExtendedStableConfig({ autoEnabled: true }), + log, + onUpdateAvailableChange, + runAutoUpdate, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + await runExtendedStableUpdateCheck({ + cfg: createExtendedStableConfig({ autoEnabled: true }), + log, + onUpdateAvailableChange, + runAutoUpdate, + }); + + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + expect(resolveNpmChannelTag).toHaveBeenNthCalledWith(1, { + channel: "extended-stable", + timeoutMs: 2500, + }); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( + `update available (extended-stable): v2.0.0 (current v1.0.0). Run: ${formatCliCommand("openclaw update")}`, + ); + expect(onUpdateAvailableChange).toHaveBeenCalledTimes(1); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(getUpdateAvailable()).toEqual({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "extended-stable", + }); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastNotifiedVersion: "2.0.0", + lastNotifiedTag: "extended-stable", + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(readPersistedUpdateCheckState()?.autoFirstSeenVersion).toBeUndefined(); + }); + + it("does no extended-stable hint or auto work when checkOnStart is false", async () => { + await seedExtendedStableAvailability(); + vi.mocked(resolveOpenClawPackageRoot).mockClear(); + vi.mocked(checkUpdateStatus).mockClear(); + vi.mocked(resolveNpmChannelTag).mockClear(); const onUpdateAvailableChange = vi.fn(); const runAutoUpdate = createAutoUpdateSuccessMock(); - await runGatewayUpdateCheck({ - cfg: { - update: { - channel: "extended-stable", - checkOnStart: true, - auto: { enabled: true }, - }, - }, - log: { info: vi.fn() }, - isNixMode: false, - allowInTests: true, + await runExtendedStableUpdateCheck({ + cfg: createExtendedStableConfig({ checkOnStart: false, autoEnabled: true }), onUpdateAvailableChange, runAutoUpdate, }); @@ -515,8 +764,139 @@ describe("update-startup", () => { expect(checkUpdateStatus).not.toHaveBeenCalled(); expect(resolveNpmChannelTag).not.toHaveBeenCalled(); expect(runAutoUpdate).not.toHaveBeenCalled(); - expect(readPersistedUpdateCheckState()).toBeNull(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledOnce(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith(null); + expect(getUpdateAvailable()).toBeNull(); + }); + + it.each([ + { name: "equal", version: "1.0.0" }, + { name: "older", version: "0.9.0" }, + ])("clears stale extended-stable availability for an $name target", async ({ version }) => { + const onUpdateAvailableChange = vi.fn(); + await seedExtendedStableAvailability({ onUpdateAvailableChange }); + seedStableAutoRolloutState(); + onUpdateAvailableChange.mockClear(); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "extended-stable", + version, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + const log = { info: vi.fn() }; + + await runExtendedStableUpdateCheck({ log, onUpdateAvailableChange }); + + expect(log.info).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledOnce(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith(null); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastNotifiedVersion: "2.0.0", + lastNotifiedTag: "extended-stable", + }); + expect(readPersistedUpdateCheckState()?.lastAvailableVersion).toBeUndefined(); + expect(readPersistedUpdateCheckState()?.lastAvailableTag).toBe("extended-stable"); + expectStableAutoRolloutStatePreserved(); + }); + + it.each(["selector_missing", "selector_query_failed", "exact_package_mismatch"] as const)( + "clears stale extended-stable availability when exact resolution fails with %s", + async (failure) => { + const onUpdateAvailableChange = vi.fn(); + await seedExtendedStableAvailability({ onUpdateAvailableChange }); + seedStableAutoRolloutState(); + onUpdateAvailableChange.mockClear(); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "extended-stable", + version: null, + reason: failure, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + const log = { info: vi.fn() }; + + await runExtendedStableUpdateCheck({ log, onUpdateAvailableChange }); + + expect(log.info).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledOnce(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith(null); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()?.lastAvailableVersion).toBeUndefined(); + expect(readPersistedUpdateCheckState()?.lastAvailableTag).toBe("extended-stable"); + expectStableAutoRolloutStatePreserved(); + expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled(); + expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); + + await runExtendedStableUpdateCheck({ log, onUpdateAvailableChange }); + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + }, + ); + + it("preserves cross-channel persisted availability when extended-stable resolution fails", async () => { + writePersistedUpdateCheckState({ + lastCheckedAt: "2026-01-16T10:00:00.000Z", + lastAvailableVersion: "2.0.0", + lastAvailableTag: "latest", + }); + mockPackageInstallStatus(); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "extended-stable", + version: null, + reason: "selector_query_failed", + }); + const onUpdateAvailableChange = vi.fn(); + + await runExtendedStableUpdateCheck({ onUpdateAvailableChange }); + expect(onUpdateAvailableChange).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastAvailableVersion: "2.0.0", + lastAvailableTag: "latest", + }); + }); + + it("does not resolve the npm channel for an extended-stable Git install", async () => { + await seedExtendedStableAvailability(); + seedStableAutoRolloutState(); + resetUpdateAvailableStateForTest(); + vi.mocked(resolveOpenClawPackageRoot).mockClear(); + vi.mocked(checkUpdateStatus).mockClear(); + vi.mocked(resolveNpmChannelTag).mockClear(); + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/openclaw", + installKind: "git", + packageManager: "unknown", + } satisfies UpdateCheckResult); + const runAutoUpdate = createAutoUpdateSuccessMock(); + const onUpdateAvailableChange = vi.fn(); + + await runExtendedStableUpdateCheck({ onUpdateAvailableChange, runAutoUpdate }); + + expect(checkUpdateStatus).toHaveBeenCalledTimes(1); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).not.toHaveBeenCalled(); + expect(getUpdateAvailable()).toBeNull(); + expect(readPersistedUpdateCheckState()).toMatchObject({ + lastAvailableVersion: "2.0.0", + lastAvailableTag: "extended-stable", + }); + expectStableAutoRolloutStatePreserved(); + }); + + it("skips all extended-stable work in Nix mode", async () => { + const runAutoUpdate = createAutoUpdateSuccessMock(); + + await runExtendedStableUpdateCheck({ isNixMode: true, runAutoUpdate }); + + expect(resolveOpenClawPackageRoot).not.toHaveBeenCalled(); + expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(readPersistedUpdateCheckState()).toBeNull(); }); it("defers stable auto-update until rollout window is due", async () => { @@ -765,17 +1145,44 @@ describe("update-startup", () => { stop(); }); - it("does not schedule recurring checks for extended-stable", async () => { + it("schedules an initial and recurring 24-hour extended-stable hint check with cleanup", async () => { + mockPackageUpdateStatus("extended-stable", "2.0.0"); + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; const stop = scheduleGatewayUpdateCheck({ cfg: { update: { channel: "extended-stable" } }, log: { info: vi.fn() }, isNixMode: false, }); - await vi.runAllTimersAsync(); + try { + await vi.advanceTimersByTimeAsync(0); + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + + stop(); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + expect(resolveNpmChannelTag).toHaveBeenCalledTimes(2); + } finally { + stop(); + process.env.NODE_ENV = previousNodeEnv; + } + }); + + it("does not schedule extended-stable polling when checkOnStart is false", async () => { + const stop = scheduleGatewayUpdateCheck({ + cfg: { update: { channel: "extended-stable", checkOnStart: false } }, + log: { info: vi.fn() }, + isNixMode: false, + }); + + await vi.advanceTimersByTimeAsync(48 * 60 * 60 * 1000); expect(resolveOpenClawPackageRoot).not.toHaveBeenCalled(); expect(checkUpdateStatus).not.toHaveBeenCalled(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); stop(); }); }); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index f247ec6e1ef..102024b213f 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -30,7 +30,12 @@ import { scheduleGatewaySigusr1Restart, } from "./restart.js"; import { detectRespawnSupervisor, type RespawnSupervisor } from "./supervisor-markers.js"; -import { normalizeUpdateChannel, DEFAULT_PACKAGE_CHANNEL } from "./update-channels.js"; +import { + channelToNpmTag, + normalizeUpdateChannel, + DEFAULT_PACKAGE_CHANNEL, + type UpdateChannel, +} from "./update-channels.js"; import { compareSemverStrings, resolveNpmChannelTag, checkUpdateStatus } from "./update-check.js"; import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "./update-control-plane-sentinel.js"; import { startManagedServiceUpdateHandoff } from "./update-managed-service-handoff.js"; @@ -233,23 +238,51 @@ function setUpdateAvailableCache(params: { params.onUpdateAvailableChange?.(params.next); } -function resolvePersistedUpdateAvailable(state: UpdateCheckState): UpdateAvailable | null { +function isPersistedAvailabilityForChannel(params: { + state: UpdateCheckState; + channel: UpdateChannel; +}): boolean { + const tag = params.state.lastAvailableTag?.trim(); + if (params.channel === "stable") { + return !tag || tag === "latest"; + } + if (params.channel === "beta") { + return tag === "beta" || tag === "latest"; + } + return tag === params.channel; +} + +function resolvePersistedUpdateAvailable( + state: UpdateCheckState, + channel: UpdateChannel, +): UpdateAvailable | null { const latestVersion = state.lastAvailableVersion?.trim(); - if (!latestVersion) { + if (!latestVersion || !isPersistedAvailabilityForChannel({ state, channel })) { return null; } const cmp = compareSemverStrings(VERSION, latestVersion); if (cmp == null || cmp >= 0) { return null; } - const channel = state.lastAvailableTag?.trim() || DEFAULT_PACKAGE_CHANNEL; + const persistedTag = state.lastAvailableTag?.trim() || channelToNpmTag(channel); return { currentVersion: VERSION, latestVersion, - channel, + channel: persistedTag, }; } +function clearPersistedAvailabilityForChannel( + nextState: UpdateCheckState, + channel: UpdateChannel, +): void { + if (!isPersistedAvailabilityForChannel({ state: nextState, channel })) { + return; + } + delete nextState.lastAvailableVersion; + delete nextState.lastAvailableTag; +} + function resolveStableJitterMs(params: { installId: string; version: string; @@ -466,6 +499,21 @@ function clearAutoState(nextState: UpdateCheckState): void { delete nextState.autoFirstSeenAt; } +async function resolveStartupInstallStatus() { + const root = await resolveOpenClawPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + const status = await checkUpdateStatus({ + root, + timeoutMs: 2500, + fetchGit: false, + includeRegistry: false, + }); + return { root, status }; +} + export async function runGatewayUpdateCheck(params: { cfg: OpenClawConfig; log: { info: (msg: string, meta?: Record) => void }; @@ -487,28 +535,45 @@ export async function runGatewayUpdateCheck(params: { } const configuredChannel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; - if (configuredChannel === "extended-stable") { - setUpdateAvailableCache({ - next: null, - onUpdateAvailableChange: params.onUpdateAvailableChange, - }); - return; - } const auto = resolveAutoUpdatePolicy(params.cfg); const autoDisabledByEnv = isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE); - const shouldRunAutoUpdate = auto.enabled && !autoDisabledByEnv; + const isAutoUpdateChannel = configuredChannel === "stable" || configuredChannel === "beta"; + const shouldRunAutoUpdate = isAutoUpdateChannel && auto.enabled && !autoDisabledByEnv; const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false; if (!shouldRunUpdateHints && !shouldRunAutoUpdate) { + if (configuredChannel === "extended-stable") { + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } return; } + let installStatus: Awaited> | undefined; + if (configuredChannel === "extended-stable") { + installStatus = await resolveStartupInstallStatus(); + if (installStatus.status.installKind !== "package") { + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + return; + } + } + const state = await readState(); const rawNow = Date.now(); const now = resolveUpdateCheckNowMs(rawNow); const rawNowIsValid = asDateTimestampMs(rawNow) !== undefined; const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null; + const persistedAvailable = shouldRunUpdateHints + ? resolvePersistedUpdateAvailable(state, configuredChannel) + : null; + const hasExtendedStableCheckMarker = state.lastAvailableTag?.trim() === "extended-stable"; + const shouldBypassSharedThrottle = + configuredChannel === "extended-stable" && !hasExtendedStableCheckMarker; if (shouldRunUpdateHints) { - const persistedAvailable = resolvePersistedUpdateAvailable(state); setUpdateAvailableCache({ next: persistedAvailable, onUpdateAvailableChange: params.onUpdateAvailableChange, @@ -522,23 +587,19 @@ export async function runGatewayUpdateCheck(params: { const checkIntervalMs = shouldRunAutoUpdate ? resolveCheckIntervalMs(params.cfg) : UPDATE_CHECK_INTERVAL_MS; - if (rawNowIsValid && lastCheckedAt && Number.isFinite(lastCheckedAt)) { + if ( + !shouldBypassSharedThrottle && + rawNowIsValid && + lastCheckedAt && + Number.isFinite(lastCheckedAt) + ) { if (now - lastCheckedAt < checkIntervalMs) { return; } } - const root = await resolveOpenClawPackageRoot({ - moduleUrl: import.meta.url, - argv1: process.argv[1], - cwd: process.cwd(), - }); - const status = await checkUpdateStatus({ - root, - timeoutMs: 2500, - fetchGit: false, - includeRegistry: false, - }); + installStatus ??= await resolveStartupInstallStatus(); + const { root, status } = installStatus; const nextState: UpdateCheckState = { ...state, @@ -560,6 +621,16 @@ export async function runGatewayUpdateCheck(params: { const resolved = await resolveNpmChannelTag({ channel, timeoutMs: 2500 }); const tag = resolved.tag; if (!resolved.version) { + if (channel === "extended-stable") { + clearPersistedAvailabilityForChannel(nextState, channel); + if (!nextState.lastAvailableVersion) { + nextState.lastAvailableTag = channel; + } + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } await writeState(nextState); return; } @@ -589,7 +660,7 @@ export async function runGatewayUpdateCheck(params: { nextState.lastNotifiedTag = tag; } - if (auto.enabled && autoDisabledByEnv) { + if (channel !== "extended-stable" && auto.enabled && autoDisabledByEnv) { params.log.info("auto-update disabled by OPENCLAW_NO_AUTO_UPDATE", { version: resolved.version, tag, @@ -673,9 +744,16 @@ export async function runGatewayUpdateCheck(params: { } } } else { - delete nextState.lastAvailableVersion; - delete nextState.lastAvailableTag; - clearAutoState(nextState); + if (channel === "extended-stable") { + clearPersistedAvailabilityForChannel(nextState, channel); + if (!nextState.lastAvailableVersion) { + nextState.lastAvailableTag = channel; + } + } else { + delete nextState.lastAvailableVersion; + delete nextState.lastAvailableTag; + clearAutoState(nextState); + } setUpdateAvailableCache({ next: null, onUpdateAvailableChange: params.onUpdateAvailableChange, @@ -692,7 +770,7 @@ export function scheduleGatewayUpdateCheck(params: { onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void; }): () => void { const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; - if (channel === "extended-stable") { + if (channel === "extended-stable" && params.cfg.update?.checkOnStart === false) { return () => {}; } let stopped = false;