diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json
index b99053951129..3327b1d3a967 100644
--- a/config/control-ui-startup-budget-baseline.json
+++ b/config/control-ui-startup-budget-baseline.json
@@ -1,5 +1,5 @@
{
- "startupJsGzipBytes": 340994,
- "reason": "Ask OpenClaw companion: shell chrome toggle, palette action, scope-aware gating",
- "updatedAt": "2026-08-17"
+ "startupJsGzipBytes": 343289,
+ "reason": "Typed update recovery state, failure projection, and sidebar review routing",
+ "updatedAt": "2026-08-18"
}
diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json
index 0a367f597469..1e55b1991109 100644
--- a/docs/.i18n/glossary.zh-CN.json
+++ b/docs/.i18n/glossary.zh-CN.json
@@ -1567,6 +1567,10 @@
"source": "Troubleshooting",
"target": "故障排查"
},
+ {
+ "source": "Update troubleshooting",
+ "target": "更新故障排查"
+ },
{
"source": "Webhooks",
"target": "Webhooks"
diff --git a/docs/docs.json b/docs/docs.json
index 17111756f650..943a53dba523 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1088,6 +1088,7 @@
"pages": [
"install/backups",
"install/updating",
+ "install/update-troubleshooting",
{
"group": "Migrating",
"pages": [
diff --git a/docs/install/update-troubleshooting.md b/docs/install/update-troubleshooting.md
new file mode 100644
index 000000000000..8eeab3fff4df
--- /dev/null
+++ b/docs/install/update-troubleshooting.md
@@ -0,0 +1,87 @@
+---
+summary: "Recover from failed OpenClaw updates in the Control UI or CLI"
+read_when:
+ - An OpenClaw update failed
+ - The Gateway did not report a final update result
+title: "Update troubleshooting"
+---
+
+Start in **Control UI → Settings → Updates**. The page reads the latest recorded
+update attempt from the connected Gateway and shows its time, target, reason
+code, failing step, and bounded diagnostic detail.
+
+Control UI remediation uses typed product actions only. It leads with an
+authenticated Gateway or native action when the connected UI has the required
+capability and scope, preserves confirmations for disruptive operations, and
+keeps terminal commands as secondary host-side fallbacks. It never parses
+localized guidance or executes an arbitrary command string.
+
+## Recover in the Control UI
+
+1. Select **Check status** when the Gateway restarted, disconnected, or did not
+ report a final result. This reads `update.status`; it does not start another
+ update. Recovery controls stay disabled while the check is pending, and a
+ rejected request appears as an error on the page.
+2. Open **View details** and address the recorded failing step. Diagnostic text
+ is bounded and redacted for display; use Gateway logs when more context is
+ required.
+3. Select **Retry update** only after the cause is resolved. The Control UI uses
+ the normal confirmed update flow and states that running sessions are
+ interrupted while the Gateway restarts.
+
+The controls require a connected Gateway, support for the corresponding typed
+Gateway method, and administrator scope. When those conditions are not met, use
+the CLI fallback on the Gateway host.
+
+## Reason codes
+
+- `dirty`, `no-upstream`: repair the source checkout before retrying.
+- `deps-install-failed`, `build-failed`, `ui-build-failed`: inspect the failing
+ step, fix the dependency or build error, then retry.
+- `global-install-failed`: retry after checking package-manager ownership and
+ permissions. Re-run the installer if the package install is incomplete.
+- `doctor-failed`: run Doctor on the Gateway host, resolve its findings, then
+ retry.
+- `restart-disabled`, `restart-unavailable`: restore a supported supervisor or
+ enable Gateway restarts before retrying.
+- `restart-unhealthy`, `restart-revision-mismatch`,
+ `restart-revision-unavailable`: inspect Gateway service health and its install
+ root before retrying.
+- `managed-service-handoff-*`: check status first. If the handoff stopped, use
+ the CLI on the Gateway host to preserve the full diagnostic output.
+
+Unknown reason codes remain visible. Check the Gateway logs before retrying.
+
+## CLI fallback
+
+Run these commands on the Gateway host, not on the computer that merely has the
+Control UI open:
+
+```bash
+openclaw update status --json
+openclaw doctor --non-interactive
+openclaw update
+```
+
+Use `openclaw update --dry-run` to preview a new attempt. If a package update
+failed after installation began, follow the installer recovery steps in
+[Updating](/install/updating#alternative-re-run-the-installer).
+
+## Rollback boundary
+
+Do not restore state as the first response to an update failure. First reinstall
+known-good code while preserving current state. Restore a verified pre-update
+state snapshot only when older code cannot read the current config or database.
+See [Rollback](/install/updating#rollback).
+
+## Support diagnostics
+
+Collect the following without posting credentials, raw config, or unredacted
+process output:
+
+- OpenClaw version and install type;
+- update timestamp, target, phase, and reason code from Settings → Updates;
+- the bounded failure detail shown by **View details**;
+- `openclaw update status --json`;
+- `openclaw gateway status --deep --json`;
+- relevant redacted Gateway log lines.
diff --git a/docs/install/updating.md b/docs/install/updating.md
index cc286ff0ce45..5f26c33cd4f0 100644
--- a/docs/install/updating.md
+++ b/docs/install/updating.md
@@ -280,6 +280,10 @@ Off by default. Enable it in `~/.openclaw/openclaw.json`:
You can also choose the update channel and enable automatic updates from
**Settings → Updates** (`/settings/updates`) in the Control UI.
+Recorded failures on that page include typed **Check status** and **Retry
+update** actions when the connected Gateway supports them. See [Update
+troubleshooting](/install/update-troubleshooting) for reason codes, guided
+recovery, CLI fallbacks, and diagnostics to collect.
For a `dev` git install, opening this page refreshes the tracked upstream and
shows whether the checkout is current, ahead, diverged, unavailable, or a
specific number of commits behind. It also shows exact and relative build,
diff --git a/ui/src/app/app-host-pairing-access.test.ts b/ui/src/app/app-host-pairing-access.test.ts
index 1947c5e20e16..313e8d99c0ef 100644
--- a/ui/src/app/app-host-pairing-access.test.ts
+++ b/ui/src/app/app-host-pairing-access.test.ts
@@ -67,6 +67,7 @@ function createPairingShell(params: {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
+ recordedUpdateAttempt: null,
controlUiRefreshRequired: false,
};
const context = {
diff --git a/ui/src/app/app-host.dock-suppression.test.ts b/ui/src/app/app-host.dock-suppression.test.ts
index 37ae62dd3136..15324a198003 100644
--- a/ui/src/app/app-host.dock-suppression.test.ts
+++ b/ui/src/app/app-host.dock-suppression.test.ts
@@ -99,6 +99,7 @@ describe("OpenClaw shell dock suppression", () => {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
+ recordedUpdateAttempt: null,
controlUiRefreshRequired: false,
approvalQueue: [],
approvalBusy: false,
diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts
index 5773e80d588e..b52ba33eb938 100644
--- a/ui/src/app/app-shell-view.ts
+++ b/ui/src/app/app-shell-view.ts
@@ -398,6 +398,7 @@ export function renderApplicationShell(host: ShellViewHost) {
refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired,
onRefresh: () => host.refreshControlUi(),
onHoldUpdate: () => context.overlays.holdUpdate(),
+ onReviewUpdate: () => host.navigate("updates"),
onOpenApprovals: () => host.openApprovals(),
onRetryConnect: () => context.gateway.connect(),
onOpenNewSession: openNewSession,
@@ -434,6 +435,7 @@ export function renderApplicationShell(host: ShellViewHost) {
refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired,
onRefresh: () => host.refreshControlUi(),
onHoldUpdate: () => context.overlays.holdUpdate(),
+ onReviewUpdate: () => host.navigate("updates"),
searchQuery: host.settingsSearchQuery,
searchBlockMatches: settingsSearchBlocks,
onExit: () => host.exitSettings(),
@@ -621,6 +623,7 @@ export function renderApplicationShell(host: ShellViewHost) {
refreshRequired: overlaySnapshot.controlUiRefreshRequired,
onRefresh: () => host.refreshControlUi(),
onHoldUpdate: () => context.overlays.holdUpdate(),
+ onReviewUpdate: () => host.navigate("updates"),
})}
${pageActionsBlocked && gatewaySnapshot.phase !== "reload-required"
? html`
diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts
index 925be1bd8980..261a554a49dc 100644
--- a/ui/src/app/navigation-surface.ts
+++ b/ui/src/app/navigation-surface.ts
@@ -28,6 +28,7 @@ export function renderFloatingUpdateCard(params: {
refreshRequired: boolean;
onRefresh: () => void;
onHoldUpdate?: () => Promise;
+ onReviewUpdate?: () => void;
}) {
// A stale client must always have a visible refresh action, including during
// onboarding, even though update-available actions stay hidden there.
@@ -48,5 +49,6 @@ export function renderFloatingUpdateCard(params: {
.refreshRequired=${params.refreshRequired}
.onRefresh=${params.onRefresh}
.onHoldUpdate=${params.onHoldUpdate ?? (async () => false)}
+ .onReviewUpdate=${params.onReviewUpdate ?? (() => undefined)}
>`;
}
diff --git a/ui/src/app/overlays-types.ts b/ui/src/app/overlays-types.ts
index 4a856cbb3ef7..a1427a0318fc 100644
--- a/ui/src/app/overlays-types.ts
+++ b/ui/src/app/overlays-types.ts
@@ -1,15 +1,17 @@
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import type { DevicePairSetupAccess, DevicePairSetupLifecycle } from "../lib/device-pair-setup.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "./exec-approval.ts";
-import type { ApplicationStatusBanner } from "./update-overlay-helpers.ts";
+import type { ApplicationStatusBanner, RecordedUpdateAttempt } from "./update-overlay-helpers.ts";
export type ApplicationOverlaySnapshot = {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
heldUpdateCampaignId: string | null;
updateRunning: boolean;
+ updateStatusRefreshing: boolean;
updateReconciliationPending: boolean;
updateStatusBanner: ApplicationStatusBanner | null;
+ recordedUpdateAttempt: RecordedUpdateAttempt | null;
controlUiRefreshRequired: boolean;
approvalQueue: readonly ExecApprovalRequest[];
approvalBusy: boolean;
diff --git a/ui/src/app/overlays-update-campaign.test.ts b/ui/src/app/overlays-update-campaign.test.ts
index c779ca46ebff..9701247d3f11 100644
--- a/ui/src/app/overlays-update-campaign.test.ts
+++ b/ui/src/app/overlays-update-campaign.test.ts
@@ -4,6 +4,7 @@ import type { ApplicationGatewaySnapshot } from "./gateway.ts";
import {
client,
createGatewayHarness,
+ deferred,
flushMicrotasks,
type RequestFn,
} from "./overlays-access.test-support.ts";
@@ -49,6 +50,33 @@ describe("application update campaign overlays", () => {
overlays.dispose();
});
+ it("publishes pending and error state when a manual status refresh fails", async () => {
+ const updateStatus = deferred();
+ const request = vi.fn((method) =>
+ method === "update.status" ? updateStatus.promise : Promise.resolve({}),
+ );
+ const harness = createGatewayHarness(client(request));
+ harness.update({
+ hello: {
+ auth: { role: "operator", scopes: ["operator.admin"] },
+ } as ApplicationGatewaySnapshot["hello"],
+ });
+ const overlays = createApplicationOverlays(harness.gateway);
+
+ const refresh = overlays.refreshUpdateStatus();
+ expect(overlays.snapshot.updateStatusRefreshing).toBe(true);
+
+ updateStatus.reject(new Error("Gateway unavailable"));
+ await refresh;
+
+ expect(overlays.snapshot.updateStatusRefreshing).toBe(false);
+ expect(overlays.snapshot.updateStatusBanner).toEqual({
+ tone: "danger",
+ text: expect.stringContaining("Gateway unavailable"),
+ });
+ overlays.dispose();
+ });
+
it("hydrates campaign state from hello and update.available events", () => {
const harness = createGatewayHarness(client(async () => ({})));
harness.update({
diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts
index 1c8cb6eecd2b..1b14b942e0f8 100644
--- a/ui/src/app/overlays.ts
+++ b/ui/src/app/overlays.ts
@@ -81,8 +81,10 @@ export function createApplicationOverlays(
updateSchedule: null,
heldUpdateCampaignId: null,
updateRunning: false,
+ updateStatusRefreshing: false,
updateReconciliationPending: false,
updateStatusBanner: null,
+ recordedUpdateAttempt: null,
controlUiRefreshRequired: false,
approvalQueue: [],
approvalBusy: false,
@@ -177,6 +179,12 @@ export function createApplicationOverlays(
snapshot = { ...snapshot, updateStatusBanner };
publish();
};
+ const publishRecordedUpdateAttempt = (
+ recordedUpdateAttempt: ApplicationOverlaySnapshot["recordedUpdateAttempt"],
+ ) => {
+ snapshot = { ...snapshot, recordedUpdateAttempt };
+ publish();
+ };
const heldCampaignId = (schedule: UpdateScheduleState | null) =>
schedule?.campaign?.holdUntilMs !== undefined
? schedule.campaign.id
@@ -190,6 +198,13 @@ export function createApplicationOverlays(
getHello: () => gateway.snapshot.hello,
publish,
publishBanner: publishUpdateBanner,
+ publishRecordedAttempt: publishRecordedUpdateAttempt,
+ publishRecordedFailure: ({ attempt, banner }) => {
+ // Both facts terminate the same reconciliation. Publishing either first
+ // exposes a false success or a failure without its recorded cause.
+ snapshot = { ...snapshot, recordedUpdateAttempt: attempt, updateStatusBanner: banner };
+ publish();
+ },
onVerifiedInstall: announceVerifiedUpdateInstall,
});
const applyUpdateStatusResponse = (response: UpdateRestartStatusResponse) => {
@@ -197,6 +212,7 @@ export function createApplicationOverlays(
...snapshot,
...projectUpdateStatusResponse(response, {
updateStatusBanner: snapshot.updateStatusBanner,
+ recordedUpdateAttempt: snapshot.recordedUpdateAttempt,
heldUpdateCampaignId: snapshot.heldUpdateCampaignId,
}),
};
@@ -215,7 +231,17 @@ export function createApplicationOverlays(
getEpoch: () => connectedEpoch,
canRefresh: () => operatorAccess.canAdmin,
isCurrent: (client, epoch) => epoch === connectedEpoch && isCurrentClient(client),
+ onRefreshing: (updateStatusRefreshing) => {
+ snapshot = { ...snapshot, updateStatusRefreshing };
+ publish();
+ },
onStatus: applyUpdateStatusResponse,
+ onError: (error) => {
+ publishUpdateBanner({
+ tone: "danger",
+ text: t("updates.error", { error: formatUiError(error) }),
+ });
+ },
});
const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => {
@@ -250,7 +276,12 @@ export function createApplicationOverlays(
? resolveUnknownUpdateOutcomeBanner()
: snapshot.updateStatusBanner;
pendingUpdate = null;
- snapshot = { ...snapshot, updateRunning: false, updateStatusBanner };
+ snapshot = {
+ ...snapshot,
+ updateRunning: false,
+ updateStatusRefreshing: false,
+ updateStatusBanner,
+ };
}
}
if (accessTransition.pairingChanged) {
@@ -289,6 +320,7 @@ export function createApplicationOverlays(
updateAvailable: null,
updateSchedule: null,
updateRunning: false,
+ updateStatusRefreshing: false,
};
updateCampaignPoller.stop();
if (next.phase === "reload-required") {
@@ -438,7 +470,12 @@ export function createApplicationOverlays(
return;
}
const generation = ++updateRunGeneration;
- snapshot = { ...snapshot, updateRunning: true, updateStatusBanner: null };
+ snapshot = {
+ ...snapshot,
+ updateRunning: true,
+ updateStatusBanner: null,
+ recordedUpdateAttempt: null,
+ };
publish();
try {
// updateRunning above suspends NEW config writes (bootstrap syncs it
diff --git a/ui/src/app/update-overlay-helpers.test.ts b/ui/src/app/update-overlay-helpers.test.ts
index 2d0a5889aede..57c20dc62e62 100644
--- a/ui/src/app/update-overlay-helpers.test.ts
+++ b/ui/src/app/update-overlay-helpers.test.ts
@@ -15,6 +15,7 @@ import type {
import {
createUpdateVerificationController,
formatUpdateCampaignLabel,
+ projectUpdateStatusResponse,
resolveUpdateStatusBanner,
} from "./update-overlay-helpers.ts";
import {
@@ -88,6 +89,9 @@ async function verifyUpdate(params: {
publishBanner: (value) => {
banner = value;
},
+ publishRecordedFailure: ({ banner: value }) => {
+ banner = value;
+ },
...(params.onVerifiedInstall ? { onVerifiedInstall: params.onVerifiedInstall } : {}),
});
@@ -412,6 +416,48 @@ describe("update schedule hydration", () => {
});
describe("update status localization", () => {
+ it("projects the recorded update attempt without inferring from localized text", () => {
+ installTranslations();
+ const projected = projectUpdateStatusResponse(
+ {
+ sentinel: {
+ kind: "update",
+ status: "error",
+ ts: 123,
+ stats: {
+ mode: "git",
+ reason: "build-failed",
+ before: { sha: "before" },
+ after: { sha: "after" },
+ steps: [
+ {
+ name: "build",
+ log: { exitCode: 1, stderrTail: "first line\nType check failed" },
+ },
+ ],
+ },
+ },
+ },
+ {
+ updateStatusBanner: null,
+ recordedUpdateAttempt: null,
+ heldUpdateCampaignId: null,
+ },
+ );
+
+ expect(projected.recordedUpdateAttempt).toEqual({
+ timestampMs: 123,
+ status: "error",
+ reason: "build-failed",
+ installKind: "git",
+ installedVersion: null,
+ installedSha: "before",
+ targetVersion: null,
+ targetSha: "after",
+ failure: { step: "build", detail: "Type check failed" },
+ });
+ });
+
it("localizes known update failure guidance", () => {
const translate = installTranslations();
diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts
index a34f4a5044b0..4b822ecafe57 100644
--- a/ui/src/app/update-overlay-helpers.ts
+++ b/ui/src/app/update-overlay-helpers.ts
@@ -10,6 +10,18 @@ export type ApplicationStatusBanner = {
text: string;
};
+export type RecordedUpdateAttempt = {
+ timestampMs: number;
+ status: string;
+ reason: string;
+ installKind: string | null;
+ installedVersion: string | null;
+ installedSha: string | null;
+ targetVersion: string | null;
+ targetSha: string | null;
+ failure: UpdateFailureCause | null;
+};
+
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending";
const UPDATE_RESTART_VERIFICATION_POLL_MS = 250;
@@ -63,8 +75,11 @@ export type UpdateRestartStatusResponse = {
sentinel?: {
kind?: string;
status?: string;
+ ts?: number;
stats?: {
+ mode?: string | null;
reason?: string | null;
+ before?: { sha?: string | null; version?: string | null } | null;
after?: { sha?: string | null; version?: string | null } | null;
steps?: UpdateSentinelStep[] | null;
} | null;
@@ -75,6 +90,32 @@ export type UpdateRestartStatusResponse = {
type UpdateFailureCause = { step: string; detail: string };
+function readRecordedUpdateAttempt(
+ sentinel: UpdateRestartStatusResponse["sentinel"],
+): RecordedUpdateAttempt | null {
+ if (
+ sentinel?.kind !== "update" ||
+ !sentinel.status ||
+ sentinel.status === "ok" ||
+ isPendingUpdateHandoffSentinel(sentinel) ||
+ typeof sentinel.ts !== "number"
+ ) {
+ return null;
+ }
+ const stats = sentinel.stats;
+ return {
+ timestampMs: sentinel.ts,
+ status: sentinel.status,
+ reason: stats?.reason?.trim() || "unexpected-error",
+ installKind: stats?.mode?.trim() || null,
+ installedVersion: stats?.before?.version?.trim() || null,
+ installedSha: stats?.before?.sha?.trim() || null,
+ targetVersion: stats?.after?.version?.trim() || null,
+ targetSha: stats?.after?.sha?.trim() || null,
+ failure: readUpdateFailureCause(sentinel),
+ };
+}
+
function lastLogLine(tail: string | null | undefined): string | null {
const lines = (tail ?? "")
.split("\n")
@@ -118,12 +159,14 @@ async function requestUpdateRestartStatus(
client: Pick,
timeoutMs: number,
request: { refreshCheckout?: true } = {},
+ onError?: (error: unknown) => void,
): Promise {
try {
return await client.request("update.status", request, {
timeoutMs,
});
- } catch {
+ } catch (error) {
+ onError?.(error);
return null;
}
}
@@ -133,17 +176,38 @@ export function createUpdateStatusRefresher(params: {
getEpoch: () => number;
canRefresh: () => boolean;
isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean;
+ onRefreshing: (refreshing: boolean) => void;
onStatus: (response: UpdateRestartStatusResponse) => void;
+ onError: (error: unknown) => void;
}) {
+ let generation = 0;
return async () => {
const client = params.getClient();
const epoch = params.getEpoch();
if (!client || !params.canRefresh()) {
return;
}
- const response = await requestUpdateRestartStatus(client, 5_000, { refreshCheckout: true });
- if (response && params.isCurrent(client, epoch)) {
- params.onStatus(response);
+ const operationGeneration = ++generation;
+ const isCurrent = () => operationGeneration === generation && params.isCurrent(client, epoch);
+ params.onRefreshing(true);
+ try {
+ const response = await requestUpdateRestartStatus(
+ client,
+ 5_000,
+ { refreshCheckout: true },
+ (error) => {
+ if (isCurrent()) {
+ params.onError(error);
+ }
+ },
+ );
+ if (response && isCurrent()) {
+ params.onStatus(response);
+ }
+ } finally {
+ if (isCurrent()) {
+ params.onRefreshing(false);
+ }
}
};
}
@@ -225,6 +289,11 @@ export function createUpdateVerificationController(params: {
getHello: () => GatewayHelloOk | null;
publish: () => void;
publishBanner: (banner: ApplicationStatusBanner | null) => void;
+ publishRecordedAttempt?: (attempt: RecordedUpdateAttempt | null) => void;
+ publishRecordedFailure: (params: {
+ attempt: RecordedUpdateAttempt | null;
+ banner: ApplicationStatusBanner;
+ }) => void;
onVerifiedInstall?: (identity: { version: string | null; sha: string | null }) => void;
}) {
let generation = 0;
@@ -289,13 +358,14 @@ export function createUpdateVerificationController(params: {
}
if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") {
params.clearPending();
- params.publishBanner(
- resolveUpdateStatusBanner({
+ params.publishRecordedFailure({
+ attempt: readRecordedUpdateAttempt(sentinel),
+ banner: resolveUpdateStatusBanner({
status: "error",
...(sentinel.stats?.reason ? { reason: sentinel.stats.reason } : {}),
cause: readUpdateFailureCause(sentinel),
}),
- );
+ });
return;
}
const actualVersion = sentinel?.stats?.after?.version?.trim() || null;
@@ -308,6 +378,7 @@ export function createUpdateVerificationController(params: {
const hasActualIdentity = actualVersion !== null || actualSha !== null;
if (versionMatches && shaMatches && (hasActualIdentity || !hasExpectedIdentity)) {
params.clearPending();
+ params.publishRecordedAttempt?.(null);
params.onVerifiedInstall?.({ version: actualVersion, sha: actualSha });
params.publishBanner(null);
return;
@@ -420,10 +491,12 @@ export function projectUpdateStatusResponse(
response: UpdateRestartStatusResponse,
current: {
updateStatusBanner: ApplicationStatusBanner | null;
+ recordedUpdateAttempt: RecordedUpdateAttempt | null;
heldUpdateCampaignId: string | null;
},
): {
updateStatusBanner: ApplicationStatusBanner | null;
+ recordedUpdateAttempt: RecordedUpdateAttempt | null;
updateAvailable?: UpdateAvailable | null;
updateSchedule?: UpdateScheduleState | null;
heldUpdateCampaignId?: string | null;
@@ -443,6 +516,10 @@ export function projectUpdateStatusResponse(
cause: readUpdateFailureCause(sentinel),
})
: current.updateStatusBanner,
+ recordedUpdateAttempt:
+ sentinel?.kind === "update" && sentinel.status
+ ? readRecordedUpdateAttempt(sentinel)
+ : current.recordedUpdateAttempt,
...(Object.hasOwn(response, "updateAvailable")
? { updateAvailable: readUpdateAvailableValue(response.updateAvailable) }
: {}),
diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts
index 5bfa565b33d1..e6e05569a280 100644
--- a/ui/src/components/app-sidebar-base.ts
+++ b/ui/src/components/app-sidebar-base.ts
@@ -63,6 +63,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) refreshRequired = false;
@property({ attribute: false }) onRefresh: () => void = () => undefined;
@property({ attribute: false }) onHoldUpdate: () => Promise = async () => false;
+ @property({ attribute: false }) onReviewUpdate: () => void = () => undefined;
@property({ attribute: false }) onOpenApprovals?: () => void;
@property({ attribute: false }) onRetryConnect?: () => void;
@property({ attribute: false }) onOpenNewSession?: (
diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts
index cb08177381da..aec6b7151737 100644
--- a/ui/src/components/app-sidebar.ts
+++ b/ui/src/components/app-sidebar.ts
@@ -572,6 +572,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
.refreshRequired=${this.refreshRequired}
.onRefresh=${this.onRefresh}
.onHoldUpdate=${this.onHoldUpdate}
+ .onReviewUpdate=${this.onReviewUpdate}
>
void;
onHoldUpdate?: () => Promise;
+ onReviewUpdate?: () => void;
searchQuery: string;
searchBlockMatches?: readonly SettingsSearchBlock[];
onExit: () => void;
@@ -325,6 +326,7 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
.refreshRequired=${props.refreshRequired}
.onRefresh=${props.onRefresh}
.onHoldUpdate=${props.onHoldUpdate ?? (async () => false)}
+ .onReviewUpdate=${props.onReviewUpdate ?? (() => undefined)}
>
`;
}
diff --git a/ui/src/e2e/updates-settings.e2e.test.ts b/ui/src/e2e/updates-settings.e2e.test.ts
index 69476f386e9c..18441727f730 100644
--- a/ui/src/e2e/updates-settings.e2e.test.ts
+++ b/ui/src/e2e/updates-settings.e2e.test.ts
@@ -157,4 +157,60 @@ suite.define(() => {
},
);
});
+
+ it("keeps a failed manual status check visible", async () => {
+ await suite.withPage(
+ {
+ colorScheme: "dark",
+ locale: "en-US",
+ serviceWorkers: "block",
+ viewport: { height: 900, width: 1280 },
+ },
+ async ({ page }) => {
+ const config = { update: { auto: { enabled: false }, channel: "stable" } };
+ const gateway = await installMockGateway(page, {
+ featureMethods: ["config.get", "update.run", "update.status"],
+ methodResponses: {
+ "config.get": {
+ config,
+ hash: "updates-status-error-1",
+ issues: [],
+ raw: JSON.stringify(config),
+ runtimeConfig: config,
+ valid: true,
+ },
+ "update.status": {
+ sentinel: {
+ kind: "update",
+ status: "error",
+ ts: Date.now(),
+ stats: { mode: "package", reason: "build-failed" },
+ },
+ },
+ },
+ operatorScopes: ["operator.read", "operator.admin"],
+ });
+
+ expect((await page.goto(`${suite.server.baseUrl}settings/updates`))?.status()).toBe(200);
+ await gateway.waitForRequest("update.status");
+ const checkStatus = page.getByRole("button", { name: "Check status", exact: true });
+ await checkStatus.waitFor();
+
+ await gateway.deferNext("update.status");
+ await checkStatus.click();
+ await expect.poll(async () => (await gateway.getRequests("update.status")).length).toBe(2);
+ expect(await checkStatus.isDisabled()).toBe(true);
+
+ await gateway.rejectDeferred("update.status", {
+ code: "UNAVAILABLE",
+ message: "Gateway status is temporarily unavailable",
+ });
+ await page
+ .locator("#config-section-update .settings-status")
+ .filter({ hasText: "Gateway status is temporarily unavailable" })
+ .waitFor();
+ expect(await checkStatus.isDisabled()).toBe(false);
+ },
+ );
+ });
});
diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json
index a0343e0e196b..4cd56d38110e 100644
--- a/ui/src/i18n/.i18n/raw-copy-baseline.json
+++ b/ui/src/i18n/.i18n/raw-copy-baseline.json
@@ -379,6 +379,13 @@
"path": "ui/src/pages/config/mcp.ts",
"text": "openclaw mcp status --verbose"
},
+ {
+ "count": 1,
+ "kind": "html-text",
+ "name": "text",
+ "path": "ui/src/pages/config/updates.ts",
+ "text": "openclaw update status --json openclaw update"
+ },
{
"count": 1,
"kind": "html-attribute",
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index dc08b2b7171e..c9beeef057cb 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -415,6 +415,7 @@ export const en: TranslationMap = {
applying: "Applying update…",
},
holdOneHour: "Hold 1 h",
+ reviewUpdate: "Review update",
confirm: {
message: "Installs the available update on the connected Gateway and restarts it.",
macMessage:
@@ -486,6 +487,20 @@ export const en: TranslationMap = {
gitComparisonFailed: "Could not compare this checkout with its tracked upstream",
updateNow: "Update now",
updateNowDescription: "Install the available update and restart the Gateway.",
+ latestAttempt: "Latest update attempt",
+ attemptedAt: "Attempted",
+ attemptTarget: "Target",
+ installedIdentity: "Installed",
+ attemptInstallKind: "Attempt install type",
+ attemptReason: "Reason code",
+ failedStep: "Failure details",
+ viewDetails: "View details",
+ recoveryActions: "Recovery",
+ checkStatus: "Check status",
+ retryUpdate: "Retry update",
+ troubleshoot: "Troubleshoot updates",
+ cliFallback: "CLI fallback",
+ showCliFallback: "Show terminal commands",
},
coalescedRestart:
"Update installed. A gateway restart is already in progress; status will refresh after it reconnects.",
diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts
index d6e38661fd6a..6276bee80791 100644
--- a/ui/src/pages/config/config-page.ts
+++ b/ui/src/pages/config/config-page.ts
@@ -1052,7 +1052,9 @@ export class ConfigPage extends OpenClawLightDomElement {
private isUpdateBusy(): boolean {
const update = this.context.overlays.snapshot;
- return update.updateRunning || update.updateReconciliationPending;
+ return (
+ update.updateRunning || update.updateStatusRefreshing || update.updateReconciliationPending
+ );
}
// The update dialog outlives this page and the connection, so it reads live
@@ -1108,9 +1110,11 @@ export class ConfigPage extends OpenClawLightDomElement {
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
updateAvailable: overlaySnapshot.updateAvailable,
statusBanner: overlaySnapshot.updateStatusBanner,
+ recordedAttempt: overlaySnapshot.recordedUpdateAttempt,
configBusy: this.isCuratedConfigMutationDisabled(),
canAdmin,
canUpdate: canCallGatewayMethod(gatewaySnapshot, "update.run", "operator.admin"),
+ canCheckStatus: canCallGatewayMethod(gatewaySnapshot, "update.status", "operator.admin"),
canHoldUpdate: canCallGatewayMethod(gatewaySnapshot, "update.hold", "operator.admin"),
updateBusy: this.isUpdateBusy(),
onChannelChange: (channel) => runtimeConfig.patchForm(["update", "channel"], channel),
@@ -1127,6 +1131,7 @@ export class ConfigPage extends OpenClawLightDomElement {
viaNativeApp: false,
}),
onHoldUpdate: () => this.context.overlays.holdUpdate(),
+ onCheckStatus: () => this.context.overlays.refreshUpdateStatus(),
});
}
const includeSections = this.includeSections();
diff --git a/ui/src/pages/config/updates.test.ts b/ui/src/pages/config/updates.test.ts
index ddf94a0e5994..2f4715c0f4e6 100644
--- a/ui/src/pages/config/updates.test.ts
+++ b/ui/src/pages/config/updates.test.ts
@@ -29,9 +29,11 @@ function createProps(overrides: Partial = {}): UpdatesViewProp
channel: "stable",
},
statusBanner: null,
+ recordedAttempt: null,
configBusy: false,
canAdmin: true,
canUpdate: true,
+ canCheckStatus: true,
canHoldUpdate: true,
updateBusy: false,
nowMs: 1_000,
@@ -39,6 +41,7 @@ function createProps(overrides: Partial = {}): UpdatesViewProp
onAutomaticUpdatesChange: vi.fn(),
onUpdateNow: vi.fn(),
onHoldUpdate: vi.fn(async () => true),
+ onCheckStatus: vi.fn(async () => undefined),
...overrides,
};
}
@@ -453,6 +456,46 @@ describe("renderUpdates", () => {
expect(row("Status").querySelector(".settings-status--danger")).not.toBeNull();
});
+ it("renders the recorded failure details and typed recovery actions", () => {
+ const onUpdateNow = vi.fn();
+ const onCheckStatus = vi.fn(async () => undefined);
+ render(
+ renderUpdates(
+ createProps({
+ recordedAttempt: {
+ timestampMs: 500,
+ status: "error",
+ reason: "build-failed",
+ installKind: "git",
+ installedVersion: null,
+ installedSha: "0123456789abcdef",
+ targetVersion: null,
+ targetSha: null,
+ failure: { step: "build", detail: "Type check failed" },
+ },
+ onUpdateNow,
+ onCheckStatus,
+ }),
+ ),
+ container,
+ );
+
+ expect(row("Reason code").textContent).toContain("build-failed");
+ expect(row("Target").textContent).toContain("v2026.8.2");
+ expect(row("Installed").textContent).toContain("0123456789ab");
+ expect(row("Attempt install type").textContent).toContain("git");
+ expect(row("Failure details").textContent).toContain("Type check failed");
+ const recovery = row("Recovery");
+ recovery.querySelector("button")?.click();
+ recovery.querySelectorAll("button")[1]?.click();
+ expect(onCheckStatus).toHaveBeenCalledOnce();
+ expect(onUpdateNow).toHaveBeenCalledOnce();
+ expect(
+ container.querySelector("a[href*='update-troubleshooting']"),
+ ).not.toBeNull();
+ expect(row("CLI fallback").textContent).toContain("openclaw update status --json");
+ });
+
it("keeps read-only facts visible while locking controls for non-admins", () => {
render(
renderUpdates(createProps({ canAdmin: false, canUpdate: false, configBusy: true })),
diff --git a/ui/src/pages/config/updates.ts b/ui/src/pages/config/updates.ts
index 5bb931636543..44dfa3d9e5f5 100644
--- a/ui/src/pages/config/updates.ts
+++ b/ui/src/pages/config/updates.ts
@@ -4,7 +4,10 @@ import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { html, nothing, type TemplateResult } from "lit";
import type { UpdateAvailable, UpdateScheduleState } from "../../api/types.ts";
-import type { ApplicationStatusBanner } from "../../app/update-overlay-helpers.ts";
+import type {
+ ApplicationStatusBanner,
+ RecordedUpdateAttempt,
+} from "../../app/update-overlay-helpers.ts";
import {
formatUpdateCampaignLabel,
formatUpdateTargetLabel,
@@ -34,9 +37,11 @@ type UpdatesViewProps = {
heldUpdateCampaignId: string | null;
updateAvailable: UpdateAvailable | null;
statusBanner: ApplicationStatusBanner | null;
+ recordedAttempt: RecordedUpdateAttempt | null;
configBusy: boolean;
canAdmin: boolean;
canUpdate: boolean;
+ canCheckStatus: boolean;
canHoldUpdate: boolean;
updateBusy: boolean;
nowMs?: number;
@@ -44,8 +49,104 @@ type UpdatesViewProps = {
onAutomaticUpdatesChange: (enabled: boolean) => void;
onUpdateNow: () => void;
onHoldUpdate: () => Promise;
+ onCheckStatus: () => Promise;
};
+function formatAttemptIdentity(version: string | null, sha: string | null): string {
+ return version ? `v${version}` : sha ? sha.slice(0, 12) : t("common.unknown");
+}
+
+function renderRecordedAttempt(props: UpdatesViewProps) {
+ const attempt = props.recordedAttempt;
+ if (!attempt && !props.statusBanner) {
+ return nothing;
+ }
+ const canRetry = props.canUpdate && !props.updateBusy;
+ const recordedTarget = attempt
+ ? formatAttemptIdentity(attempt.targetVersion, attempt.targetSha)
+ : null;
+ const target =
+ recordedTarget && recordedTarget !== t("common.unknown")
+ ? recordedTarget
+ : (formatUpdateTargetLabel(props.schedule, props.updateAvailable) ?? t("common.unknown"));
+ return renderSettingsSection({ title: t("updates.page.latestAttempt") }, [
+ attempt
+ ? renderSettingsRow({
+ title: t("updates.page.attemptedAt"),
+ control: renderTimestamp(attempt.timestampMs, props.nowMs),
+ })
+ : nothing,
+ renderSettingsRow({
+ title: t("updates.page.attemptTarget"),
+ control: renderSettingsValue(target, { mono: true }),
+ }),
+ attempt
+ ? renderSettingsRow({
+ title: t("updates.page.installedIdentity"),
+ control: renderSettingsValue(
+ formatAttemptIdentity(attempt.installedVersion, attempt.installedSha),
+ { mono: true },
+ ),
+ })
+ : nothing,
+ attempt?.installKind
+ ? renderSettingsRow({
+ title: t("updates.page.attemptInstallKind"),
+ control: renderSettingsValue(attempt.installKind),
+ })
+ : nothing,
+ attempt
+ ? renderSettingsRow({
+ title: t("updates.page.attemptReason"),
+ control: renderSettingsValue(html`${attempt.reason}`, { mono: true }),
+ })
+ : nothing,
+ attempt?.failure
+ ? renderSettingsRow({
+ title: t("updates.page.failedStep"),
+ stacked: true,
+ control: html`
+ ${t("updates.page.viewDetails")}
+ ${attempt.failure.step}
+ ${attempt.failure.detail}
+ `,
+ })
+ : nothing,
+ renderSettingsRow({
+ title: t("updates.page.recoveryActions"),
+ control: html`
+ void props.onCheckStatus()}
+ >
+ ${t("updates.page.checkStatus")}
+
+
+ ${t("updates.page.retryUpdate")}
+
+
`,
+ }),
+ renderSettingsRow({
+ title: t("updates.page.cliFallback"),
+ stacked: true,
+ control: html`
+ ${t("updates.page.showCliFallback")}
+ openclaw update status --json
+openclaw update
+ `,
+ }),
+ ]);
+}
+
function readUpdatesSettings(
configObject: Record,
schedule: UpdateScheduleState | null,
@@ -311,6 +412,7 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult {
? html`${t("updates.adminRequired")}
`
: nothing,
renderBuildFacts(props),
+ renderRecordedAttempt(props),
renderSettingsSection({ title: t("updates.page.policyTitle") }, policyRows),
renderSettingsSection({ title: t("updates.page.statusTitle") }, [
renderSettingsRow({
@@ -351,6 +453,11 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult {
`,
}),
]),
+ html`
+ ${t("updates.page.troubleshoot")}
+
`,
],
{ intro: t("updates.page.intro") },
)}
diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css
index 8b768eb496cf..6c263010777e 100644
--- a/ui/src/styles/layout.css
+++ b/ui/src/styles/layout.css
@@ -1285,6 +1285,23 @@ body.update-dialog-open .sidebar-update-card__status {
margin-top: 6px;
}
+.sidebar-update-card__review {
+ width: 100%;
+ margin-top: 6px;
+ padding: 5px 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-elevated);
+ color: var(--text);
+ cursor: var(--cursor-action);
+ font-size: var(--control-ui-text-xs);
+ font-weight: 600;
+}
+
+.sidebar-update-card__review:hover {
+ background: var(--bg-hover);
+}
+
.sidebar-update-card__status--danger {
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
background: color-mix(in srgb, var(--danger) 10%, transparent);
diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css
index b645b99fd119..2cae46b338f0 100644
--- a/ui/src/styles/settings.css
+++ b/ui/src/styles/settings.css
@@ -595,6 +595,26 @@ textarea.settings-input {
gap: var(--space-3);
}
+.updates-attempt-details {
+ width: 100%;
+}
+
+.updates-attempt-details summary {
+ cursor: var(--cursor-action);
+ font-weight: 600;
+}
+
+.updates-attempt-details pre {
+ margin: var(--space-2) 0 0;
+ padding: var(--space-3);
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-elevated);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
.updates-commit-list {
width: 100%;
overflow: hidden;