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
This commit is contained in:
Kevin Lin 2026-07-06 19:57:03 -07:00 committed by GitHub
parent ad833d7883
commit e3c40efeb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 687 additions and 83 deletions

View file

@ -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,

View file

@ -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.
---

View file

@ -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`

View file

@ -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

View file

@ -71,9 +71,11 @@ export const FIELD_HELP: Record<string, string> = {
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":

View file

@ -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<typeof import("./openclaw-root.js")>("./openclaw-root.js");
return {
...actual,
resolveOpenClawPackageRoot: vi.fn(async () => "/opt/openclaw"),
};
});
vi.mock("./update-check.js", async () => {
const actual = await vi.importActual<typeof import("./update-check.js")>("./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<void>((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<void>((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();
});
});

View file

@ -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<typeof createExtendedStableConfig>;
log?: Parameters<typeof runGatewayUpdateCheck>[0]["log"];
onUpdateAvailableChange?: Parameters<
typeof runGatewayUpdateCheck
>[0]["onUpdateAvailableChange"];
runAutoUpdate?: ReturnType<typeof createAutoUpdateSuccessMock>;
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<string, unknown> };
runAutoUpdate?: ReturnType<typeof createAutoUpdateSuccessMock>;
@ -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();
});
});

View file

@ -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<string, unknown>) => 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<ReturnType<typeof resolveStartupInstallStatus>> | 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;