From b830beb34b88fa958d783ffa95413507d00b1381 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 07:09:34 +0100 Subject: [PATCH] fix: surface update restart and plugin repair guidance --- CHANGELOG.md | 2 + docs/channels/troubleshooting.md | 19 +++ docs/cli/update.md | 24 ++-- docs/gateway/troubleshooting.md | 24 ++++ scripts/e2e/status-corrupt-plugin-deps.sh | 155 +++++++++++++++++++++ src/cli/update-cli.test.ts | 21 +++ src/cli/update-cli/update-command.ts | 8 +- src/commands/status-all/diagnosis.test.ts | 53 +++++++ src/commands/status-all/diagnosis.ts | 13 ++ src/commands/status-all/report-data.ts | 2 + src/commands/status-overview-rows.test.ts | 12 ++ src/commands/status-overview-rows.ts | 8 ++ src/commands/status-update-restart.test.ts | 82 +++++++++++ src/commands/status-update-restart.ts | 86 ++++++++++++ src/commands/status.command-report-data.ts | 2 + src/commands/status.command.ts | 12 ++ 16 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 scripts/e2e/status-corrupt-plugin-deps.sh create mode 100644 src/commands/status-update-restart.test.ts create mode 100644 src/commands/status-update-restart.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbcc212d2e6..f4fc0e51fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ Docs: https://docs.openclaw.ai - Cron: treat attempt dispatch and assembled context as execution-start milestones so isolated agent jobs that have reached backend dispatch are governed by their configured job timeout instead of the 60s pre-execution watchdog. Fixes #81368. (#81871) Thanks @alexph-dev. - Doctor/auth: warn about stale per-agent OAuth auth profile shadows and let `openclaw doctor --fix` remove the local shadow so agents inherit the fresher main-agent credential. - Status/channels: show configured channels whose plugin setup failed to load as `plugin load failed: dependency tree corrupted; run openclaw doctor --fix` instead of silently dropping them from `openclaw status`. +- Status/update: show pending or failed update restart handoffs in `openclaw status` and make `openclaw update` print explicit gateway restart verified, skipped, or failed guidance. +- QA/update: add an E2E corrupt plugin dependency lane that verifies `status --all` guidance, `doctor --fix` cleanup, and channel status recovery. - Discord/channels: make `openclaw channels list --all` prefer reachable Gateway runtime account status and mark configured-but-unavailable credentials, avoiding false `not configured` output when Discord is running from service-only env. Fixes #79343. Thanks @EricY019. - WhatsApp: mark text slash commands as command turns so authorized group command replies stay visible under message-tool-only group reply mode. (#81972) Thanks @barbarhan. - Providers/OpenCode Go: stop sending unsupported reasoning parameters to Kimi K2.5/K2.6, avoiding OpenCode Go payload-validation failures while preserving DeepSeek V4 reasoning support. diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md index 6b09a2de9df..6f5a1589b5f 100644 --- a/docs/channels/troubleshooting.md +++ b/docs/channels/troubleshooting.md @@ -27,6 +27,25 @@ Healthy baseline: - `Capability: read-only`, `write-capable`, or `admin-capable` - Channel probe shows transport connected and, where supported, `works` or `audit ok` +## After an update + +Use this when Telegram, iMessage, BlueBubbles-era configs, or another plugin +channel disappears after updating. + +```bash +openclaw status --all +openclaw doctor --fix +openclaw gateway restart +openclaw status --all +``` + +Look for `plugin load failed: dependency tree corrupted; run openclaw doctor +--fix` in `openclaw status --all`. That means the channel is configured, but +the plugin setup/load path hit a corrupt dependency tree instead of registering +the channel. `openclaw doctor --fix` removes stale plugin dependency staging +directories and stale auth shadows, then `openclaw gateway restart` reloads the +clean state. + ## WhatsApp ### WhatsApp failure signatures diff --git a/docs/cli/update.md b/docs/cli/update.md index 4799452a792..b619b69926f 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -121,15 +121,17 @@ When a local managed Gateway service is installed and restart is enabled, package-manager updates stop the running service before replacing the package tree, then refresh the service metadata from the updated install, restart the service, and verify the restarted Gateway reports the expected version before -reporting success. On macOS, the post-update check also verifies the LaunchAgent -is loaded/running for the active profile and the configured loopback port is -healthy. If the plist is installed but launchd is not supervising it, OpenClaw -re-bootstraps the LaunchAgent automatically, then reruns the -health/version/channel readiness checks. A fresh bootstrap loads the RunAtLoad -job directly, so update recovery does not immediately `kickstart -k` the newly -spawned Gateway. If the Gateway still does not become healthy, the command exits -non-zero and prints the restart log path plus explicit restart, reinstall, and -package rollback instructions. With `--no-restart`, +reporting `Gateway: restarted and verified.`. On macOS, the post-update check +also verifies the LaunchAgent is loaded/running for the active profile and the +configured loopback port is healthy. If the plist is installed but launchd is +not supervising it, OpenClaw re-bootstraps the LaunchAgent automatically, then +reruns the health/version/channel readiness checks. A fresh bootstrap loads the +RunAtLoad job directly, so update recovery does not immediately `kickstart -k` +the newly spawned Gateway. If the Gateway still does not become healthy, the +command exits non-zero and prints the restart log path plus explicit restart, +reinstall, and package rollback instructions. If restart cannot run, the command +prints `Gateway: restart skipped (...)` or `Gateway: restart failed: ...` with a +manual `openclaw gateway restart` hint. With `--no-restart`, package replacement still runs but the managed service is not stopped or restarted, so the running Gateway may keep old code until you restart it manually. @@ -158,7 +160,9 @@ health checks complete. During the handoff, the sentinel can carry `stats.reason: "restart-health-pending"` with no success continuation; the restarted Gateway keeps polling it and only fires the continuation after the CLI has verified service health and rewritten the sentinel with the final `ok` -result. `update.status` returns the latest cached sentinel. +result. `openclaw status` and `openclaw status --all` show an `Update restart` +row while that sentinel is pending or failed, and `update.status` returns the +latest cached sentinel. ## Git checkout flow diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index f158f5c85e4..15ce3ecdb44 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -27,6 +27,30 @@ Expected healthy signals: - `openclaw doctor` reports no blocking config/service issues. - `openclaw channels status --probe` shows live per-account transport status and, where supported, probe/audit results such as `works` or `audit ok`. +## After an update + +Use this when an update finishes but the Gateway is down, channels are empty, or +model calls start failing with 401s. + +```bash +openclaw status --all +openclaw update status --json +openclaw gateway status --deep +openclaw doctor --fix +openclaw gateway restart +``` + +Look for: + +- `Update restart` in `openclaw status` / `openclaw status --all`. Pending or + failed handoffs include the next command to run. +- `plugin load failed: dependency tree corrupted; run openclaw doctor --fix` + under Channels. That means the channel config still exists, but plugin + registration failed before the channel could load. +- provider 401s after re-auth. `openclaw doctor --fix` checks for stale + per-agent OAuth auth shadows and removes the old copies so all agents resolve + the current shared profile. + ## Split brain installs and newer config guard Use this when a gateway service unexpectedly stops after an update, or logs show that one `openclaw` binary is older than the version that last wrote `openclaw.json`. diff --git a/scripts/e2e/status-corrupt-plugin-deps.sh b/scripts/e2e/status-corrupt-plugin-deps.sh new file mode 100644 index 00000000000..4d351961181 --- /dev/null +++ b/scripts/e2e/status-corrupt-plugin-deps.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Verifies status/doctor UX for a configured plugin channel whose setup entry +# fails because a staged dependency tree is corrupt. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-status-corrupt-plugin-deps.XXXXXX")" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +HOME_DIR="$TMP_DIR/home" +STATE_DIR="$TMP_DIR/state" +CONFIG_PATH="$TMP_DIR/openclaw.json" +PLUGIN_DIR="$TMP_DIR/plugin" +STAGE_DIR="$TMP_DIR/stage" +mkdir -p "$HOME_DIR" "$STATE_DIR" "$PLUGIN_DIR" "$STAGE_DIR/node_modules/ansi-escapes" +printf "corrupt rename residue\n" > "$STAGE_DIR/node_modules/ansi-escapes/.openclaw-rename-tmp" + +cat > "$PLUGIN_DIR/package.json" <<'JSON' +{ + "name": "@example/openclaw-e2e-corrupt-chat", + "version": "1.0.0", + "openclaw": { + "extensions": ["./index.cjs"], + "setupEntry": "./setup-entry.cjs" + } +} +JSON + +cat > "$PLUGIN_DIR/openclaw.plugin.json" <<'JSON' +{ + "id": "e2e-corrupt-chat", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "channelConfigs": { + "e2e-corrupt-chat": { + "label": "E2E Corrupt Chat", + "description": "E2E corrupt dependency fixture", + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "token": { "type": "string" } + } + } + } + }, + "channels": ["e2e-corrupt-chat"], + "channelEnvVars": { + "e2e-corrupt-chat": ["E2E_CORRUPT_CHAT_TOKEN"] + } +} +JSON + +cat > "$PLUGIN_DIR/index.cjs" <<'JS' +module.exports = { + id: "e2e-corrupt-chat", + register() {} +}; +JS + +cat > "$PLUGIN_DIR/setup-entry.cjs" <<'JS' +const fs = require("node:fs"); +const path = require("node:path"); + +const stageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR || ""; +const renameResidue = path.join(stageDir, "node_modules", "ansi-escapes", ".openclaw-rename-tmp"); +if (fs.existsSync(renameResidue)) { + const err = new Error("ENOTEMPTY: directory not empty, rename 'ansi-escapes'"); + err.code = "ENOTEMPTY"; + throw err; +} + +const plugin = { + id: "e2e-corrupt-chat", + meta: { + id: "e2e-corrupt-chat", + label: "E2E Corrupt Chat", + selectionLabel: "E2E Corrupt Chat", + docsPath: "/channels/e2e-corrupt-chat", + blurb: "E2E corrupt dependency fixture" + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + isEnabled: () => true, + isConfigured: () => true, + hasConfiguredState: () => true + }, + outbound: { deliveryMode: "direct" } +}; + +module.exports = { plugin }; +JS + +cat > "$CONFIG_PATH" < "$BEFORE" +grep -F "e2e-corrupt-chat" "$BEFORE" >/dev/null +grep -F "plugin load failed: dependency tree corrupted; run openclaw doctor --fix" "$BEFORE" >/dev/null + +run_openclaw doctor --fix --non-interactive --yes > "$DOCTOR" +if [[ -e "$STAGE_DIR" ]]; then + echo "doctor --fix did not remove corrupt plugin stage dir: $STAGE_DIR" >&2 + exit 1 +fi + +run_openclaw status --all --timeout 1 > "$AFTER" +grep -F "E2E Corrupt Chat" "$AFTER" >/dev/null +if grep -F "plugin load failed: dependency tree corrupted" "$AFTER" >/dev/null; then + echo "status still reports corrupt plugin dependency tree after doctor --fix" >&2 + exit 1 +fi + +echo "Status corrupt plugin dependency E2E passed." diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 51ed62daa86..415ff613df4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -3187,6 +3187,12 @@ describe("update-cli", () => { }); expect(runRestartScript).toHaveBeenCalledTimes(1); expect(runDaemonRestart).not.toHaveBeenCalled(); + expect( + vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).toContain("Gateway: restarted and verified."); }, }, { @@ -3229,6 +3235,12 @@ describe("update-cli", () => { expect(runDaemonInstall).not.toHaveBeenCalled(); expect(runRestartScript).not.toHaveBeenCalled(); expect(runDaemonRestart).not.toHaveBeenCalled(); + expect( + vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).toContain("Gateway: restart skipped (--no-restart)."); }, }, { @@ -3244,6 +3256,9 @@ describe("update-cli", () => { expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( false, ); + expect(logLines.some((line) => line.includes("Gateway: restarted and verified."))).toBe( + false, + ); }, }, ] as const)("updateCommand service refresh behavior: $name", runUpdateCliScenario); @@ -3365,6 +3380,12 @@ describe("update-cli", () => { expect(runRestartScript).not.toHaveBeenCalled(); const probeCall = probeGatewayCall() as { includeDetails?: boolean } | undefined; expect(probeCall?.includeDetails).toBe(true); + expect( + vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"), + ).toContain("Gateway: restarted and verified."); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 7507ae0adbf..6f935293258 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1573,6 +1573,9 @@ async function maybeRestartService(params: { } if (health.healthy) { + if (!params.opts.json) { + defaultRuntime.log(theme.success("Gateway: restarted and verified.")); + } return true; } @@ -1683,7 +1686,7 @@ async function maybeRestartService(params: { await createUpdateConfigSnapshot(); restarted = await runDaemonRestart(); } else if (!refreshedGatewayAlreadyHealthy && !params.opts.json) { - defaultRuntime.log(theme.muted("No installed gateway service found; skipped restart.")); + defaultRuntime.log(theme.muted("Gateway: restart skipped (no installed service found).")); } const shouldVerifyRestart = @@ -1725,7 +1728,7 @@ async function maybeRestartService(params: { } } catch (err) { if (!params.opts.json) { - defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`)); + defaultRuntime.log(theme.warn(`Gateway: restart failed: ${String(err)}`)); defaultRuntime.log( theme.muted( `You may need to restart the service manually: ${replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME)}`, @@ -1741,6 +1744,7 @@ async function maybeRestartService(params: { if (!params.opts.json) { defaultRuntime.log(""); + defaultRuntime.log(theme.muted("Gateway: restart skipped (--no-restart).")); if (params.result.mode === "npm" || params.result.mode === "pnpm") { defaultRuntime.log( theme.muted( diff --git a/src/commands/status-all/diagnosis.test.ts b/src/commands/status-all/diagnosis.test.ts index 85216892dfe..825e33f44ca 100644 --- a/src/commands/status-all/diagnosis.test.ts +++ b/src/commands/status-all/diagnosis.test.ts @@ -144,6 +144,59 @@ describe("status-all diagnosis port checks", () => { expect(output).toContain("Port 18789 is already in use."); }); + it("adds direct update restart guidance for failed update sentinels", async () => { + const params = createBaseParams([]); + params.sentinel = { + payload: { + kind: "update", + status: "error", + ts: Date.now() - 60_000, + stats: { + mode: "npm", + reason: "managed-service-handoff-failed", + steps: [], + }, + }, + }; + + await appendStatusAllDiagnosis(params); + + const output = params.lines.join("\n"); + expect(output).toContain( + "Update restart: failed · managed-service-handoff-failed · run openclaw gateway status --deep", + ); + expect(output).toContain("Update restart failed; run openclaw gateway status --deep."); + expect(output).toContain( + "If the service is down, run openclaw gateway restart or openclaw gateway install --force.", + ); + }); + + it("adds direct update restart guidance for pending update sentinels", async () => { + const params = createBaseParams([]); + params.sentinel = { + payload: { + kind: "update", + status: "skipped", + ts: Date.now() - 60_000, + stats: { + mode: "npm", + reason: "restart-health-pending", + steps: [], + }, + }, + }; + + await appendStatusAllDiagnosis(params); + + const output = params.lines.join("\n"); + expect(output).toContain( + "Update restart: restart pending health verification · run openclaw gateway status --deep", + ); + expect(output).toContain( + "Update restart is still pending; run openclaw update status --json for handoff state.", + ); + }); + it("avoids unreachable gateway diagnosis in node-only mode", async () => { const params = createBaseParams([]); params.connectionDetailsForReport = [ diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 16492f3319c..b66782959a0 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -16,6 +16,10 @@ import { type PluginCompatibilityNotice, } from "../../plugins/status.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { + formatUpdateRestartActionLines, + formatUpdateRestartStatusValue, +} from "../status-update-restart.ts"; import type { NodeOnlyGatewayInfo } from "../status.node-mode.js"; import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; @@ -134,6 +138,15 @@ export async function appendStatusAllDiagnosis(params: { lines.push( ` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatTimeAgo(Date.now() - params.sentinel.payload.ts)}`)}`, ); + const updateRestartValue = formatUpdateRestartStatusValue(params.sentinel.payload, { + formatTimeAgo, + }); + if (updateRestartValue) { + lines.push(` ${muted(`Update restart: ${updateRestartValue}`)}`); + } + for (const line of formatUpdateRestartActionLines(params.sentinel.payload)) { + lines.push(` ${muted(line)}`); + } } else { emitCheck("Restart sentinel: none", "ok"); } diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts index 247108976c3..1310eac5d8c 100644 --- a/src/commands/status-all/report-data.ts +++ b/src/commands/status-all/report-data.ts @@ -15,6 +15,7 @@ import { resolveStatusGatewayHealthSafe, type resolveStatusServiceSummaries, } from "../status-runtime-shared.ts"; +import { formatUpdateRestartStatusValue } from "../status-update-restart.ts"; import { resolveStatusAllConnectionDetails } from "../status.gateway-connection.ts"; import type { NodeOnlyGatewayInfo } from "../status.node-mode.js"; import type { StatusScanOverviewResult } from "../status.scan-overview.ts"; @@ -179,6 +180,7 @@ export async function buildStatusAllReportData(params: { osLabel: params.overview.osSummary.label, configPath, secretDiagnosticsCount: params.overview.secretDiagnostics.length, + updateRestartValue: formatUpdateRestartStatusValue(diagnosis.sentinel?.payload), agentStatus: params.overview.agentStatus, tailscaleBackendState: diagnosis.tailscale.backendState, }); diff --git a/src/commands/status-overview-rows.test.ts b/src/commands/status-overview-rows.test.ts index 31ee7410381..eac9ba1cc9d 100644 --- a/src/commands/status-overview-rows.test.ts +++ b/src/commands/status-overview-rows.test.ts @@ -40,6 +40,16 @@ describe("status-overview-rows", () => { ); }); + it("shows update restart state in fast status output", () => { + const rows = buildStatusCommandOverviewRows( + createStatusCommandOverviewRowsParams({ + updateRestartValue: "failed · managed-service-handoff-failed", + }), + ); + + expect(findRowValue(rows, "Update restart")).toBe("failed · managed-service-handoff-failed"); + }); + it("builds status-all overview rows from the shared surface", () => { const rows = buildStatusAllOverviewRows({ surface: { @@ -51,6 +61,7 @@ describe("status-overview-rows", () => { osLabel: "macOS", configPath: "/tmp/openclaw.json", secretDiagnosticsCount: 2, + updateRestartValue: "restart pending health verification", agentStatus: { bootstrapPendingCount: 1, totalSessions: 2, @@ -62,6 +73,7 @@ describe("status-overview-rows", () => { expect(findRowValue(rows, "Version")).toBe(VERSION); expect(findRowValue(rows, "OS")).toBe("macOS"); expect(findRowValue(rows, "Config")).toBe("/tmp/openclaw.json"); + expect(findRowValue(rows, "Update restart")).toBe("restart pending health verification"); expect(findRowValue(rows, "Security")).toBe("Run: openclaw security audit --deep"); expect(findRowValue(rows, "Secrets")).toBe("2 diagnostics"); }); diff --git a/src/commands/status-overview-rows.ts b/src/commands/status-overview-rows.ts index 6d8f8f95442..8029323941b 100644 --- a/src/commands/status-overview-rows.ts +++ b/src/commands/status-overview-rows.ts @@ -91,6 +91,7 @@ export function buildStatusCommandOverviewRows( formatTimeAgo: (ageMs: number) => string; formatKTokens: (value: number) => string; updateValue?: string; + updateRestartValue?: string | null; } & StatusMemoryStateResolvers, ) { const agentsValue = buildStatusAgentsValue({ @@ -156,6 +157,9 @@ export function buildStatusCommandOverviewRows( agentsValue, suffixRows: [ ...(modelPricingValue ? [{ Item: "Model pricing", Value: modelPricingValue }] : []), + ...(params.updateRestartValue + ? [{ Item: "Update restart", Value: params.updateRestartValue }] + : []), { Item: "Memory", Value: memoryValue }, { Item: "Plugin compatibility", Value: pluginCompatibilityValue }, { Item: "Probes", Value: probesValue }, @@ -182,6 +186,7 @@ export function buildStatusAllOverviewRows(params: { osLabel: string; configPath: string; secretDiagnosticsCount: number; + updateRestartValue?: string | null; agentStatus: { bootstrapPendingCount: number; totalSessions: number; @@ -205,6 +210,9 @@ export function buildStatusAllOverviewRows(params: { { Item: "Config", Value: params.configPath }, ], middleRows: [ + ...(params.updateRestartValue + ? [{ Item: "Update restart", Value: params.updateRestartValue }] + : []), { Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` }, ], agentsValue: buildStatusAllAgentsValue({ diff --git a/src/commands/status-update-restart.test.ts b/src/commands/status-update-restart.test.ts new file mode 100644 index 00000000000..e3f2c7d1027 --- /dev/null +++ b/src/commands/status-update-restart.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { RestartSentinelPayload } from "../infra/restart-sentinel.js"; +import { + formatUpdateRestartActionLines, + formatUpdateRestartStatusValue, +} from "./status-update-restart.ts"; + +const basePayload = { + kind: "update", + status: "skipped", + ts: 1_000, + stats: { mode: "npm", steps: [] }, +} satisfies RestartSentinelPayload; + +describe("status update restart formatting", () => { + it("surfaces failed update restarts with deep status guidance", () => { + const value = formatUpdateRestartStatusValue( + { + ...basePayload, + status: "error", + stats: { ...basePayload.stats, reason: "managed-service-handoff-failed" }, + }, + { nowMs: 61_000, formatTimeAgo: (ageMs) => `${ageMs}ms`, warn: (text) => `warn(${text})` }, + ); + + expect(value).toBe( + "warn(failed · managed-service-handoff-failed · run openclaw gateway status --deep · 60000ms)", + ); + }); + + it("labels handoff and health-pending sentinels as restart pending", () => { + expect( + formatUpdateRestartStatusValue({ + ...basePayload, + stats: { ...basePayload.stats, reason: "managed-service-handoff-started" }, + }), + ).toBe("handoff running · gateway restart pending · run openclaw update status"); + expect( + formatUpdateRestartStatusValue({ + ...basePayload, + stats: { ...basePayload.stats, reason: "restart-health-pending" }, + }), + ).toBe("restart pending health verification · run openclaw gateway status --deep"); + }); + + it("formats verified update restarts with the running gateway version", () => { + expect( + formatUpdateRestartStatusValue( + { + ...basePayload, + status: "ok", + stats: { ...basePayload.stats, after: { version: "2026.5.15" } }, + }, + { ok: (text) => `ok(${text})` }, + ), + ).toBe("ok(verified · gateway 2026.5.15)"); + }); + + it("adds action lines for failed and pending update restarts only", () => { + expect( + formatUpdateRestartActionLines({ + ...basePayload, + status: "error", + stats: { ...basePayload.stats, reason: "managed-service-handoff-failed" }, + }), + ).toContain("Update restart failed; run openclaw gateway status --deep."); + expect( + formatUpdateRestartActionLines({ + ...basePayload, + stats: { ...basePayload.stats, reason: "restart-health-pending" }, + }), + ).toContain( + "Update restart is still pending; run openclaw update status --json for handoff state.", + ); + expect( + formatUpdateRestartActionLines({ + ...basePayload, + status: "ok", + }), + ).toEqual([]); + }); +}); diff --git a/src/commands/status-update-restart.ts b/src/commands/status-update-restart.ts new file mode 100644 index 00000000000..a1931bec85b --- /dev/null +++ b/src/commands/status-update-restart.ts @@ -0,0 +1,86 @@ +import type { RestartSentinelPayload } from "../infra/restart-sentinel.js"; +import { + CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON, + CONTROL_PLANE_UPDATE_RESTART_HEALTH_PENDING_REASON, +} from "../infra/update-control-plane-sentinel.js"; + +type Formatter = (value: string) => string; + +function readReason(payload: RestartSentinelPayload): string | null { + const reason = payload.stats?.reason; + return typeof reason === "string" && reason.trim().length > 0 ? reason : null; +} + +function readAfterVersion(payload: RestartSentinelPayload): string | null { + const version = payload.stats?.after?.version; + return typeof version === "string" && version.trim().length > 0 ? version : null; +} + +export function formatUpdateRestartStatusValue( + payload: RestartSentinelPayload | null | undefined, + opts: { + ok?: Formatter; + warn?: Formatter; + muted?: Formatter; + nowMs?: number; + formatTimeAgo?: (ageMs: number) => string; + } = {}, +): string | null { + if (!payload || payload.kind !== "update") { + return null; + } + + const age = + opts.formatTimeAgo && Number.isFinite(payload.ts) + ? ` · ${opts.formatTimeAgo(Math.max(0, (opts.nowMs ?? Date.now()) - payload.ts))}` + : ""; + const reason = readReason(payload); + const warn = opts.warn ?? ((value: string) => value); + const ok = opts.ok ?? ((value: string) => value); + const muted = opts.muted ?? ((value: string) => value); + + if (payload.status === "error") { + return warn( + `failed · ${reason ?? "restart failed"} · run openclaw gateway status --deep${age}`, + ); + } + + if (payload.status === "skipped") { + if (reason === CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON) { + return warn(`handoff running · gateway restart pending · run openclaw update status${age}`); + } + if (reason === CONTROL_PLANE_UPDATE_RESTART_HEALTH_PENDING_REASON) { + return warn(`restart pending health verification · run openclaw gateway status --deep${age}`); + } + return muted(`skipped · ${reason ?? "restart skipped"}${age}`); + } + + const version = readAfterVersion(payload); + return ok(`verified${version ? ` · gateway ${version}` : ""}${age}`); +} + +export function formatUpdateRestartActionLines( + payload: RestartSentinelPayload | null | undefined, +): string[] { + if (!payload || payload.kind !== "update") { + return []; + } + if (payload.status === "error") { + return [ + "Update restart failed; run openclaw gateway status --deep.", + "If the service is down, run openclaw gateway restart or openclaw gateway install --force.", + ]; + } + const reason = readReason(payload); + if ( + payload.status === "skipped" && + (reason === CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON || + reason === CONTROL_PLANE_UPDATE_RESTART_HEALTH_PENDING_REASON) + ) { + return [ + "Update restart is still pending; run openclaw update status --json for handoff state.", + "If it stays pending, run openclaw gateway status --deep.", + ]; + } + return []; +} diff --git a/src/commands/status.command-report-data.ts b/src/commands/status.command-report-data.ts index e32937e3c4a..40450eeb154 100644 --- a/src/commands/status.command-report-data.ts +++ b/src/commands/status.command-report-data.ts @@ -82,6 +82,7 @@ export async function buildStatusCommandReportData( formatUpdateAvailableHint: (update: StatusOverviewSurface["update"]) => string | null; accentDim: (value: string) => string; updateValue?: string; + updateRestartValue?: string | null; theme: { heading: (value: string) => string; muted: (value: string) => string; @@ -111,6 +112,7 @@ export async function buildStatusCommandReportData( resolveMemoryFtsState: params.resolveMemoryFtsState, resolveMemoryCacheSummary: params.resolveMemoryCacheSummary, updateValue: params.updateValue, + updateRestartValue: params.updateRestartValue, }); const sessionsColumns = [ diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 7347ab8b3fd..293d7ab7658 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -5,6 +5,7 @@ import { readPairingConnectErrorDetails, type ConnectPairingRequiredReason, } from "../gateway/protocol/connect-error-details.js"; +import { readRestartSentinel } from "../infra/restart-sentinel.js"; import { type RuntimeEnv } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; @@ -17,6 +18,7 @@ import { resolveStatusRuntimeSnapshot, resolveStatusUsageSummary, } from "./status-runtime-shared.ts"; +import { formatUpdateRestartStatusValue } from "./status-update-restart.ts"; import { buildStatusCommandReportData } from "./status.command-report-data.ts"; import { buildStatusCommandReportLines } from "./status.command-report.ts"; import { logGatewayConnectionDetails } from "./status.gateway-connection.ts"; @@ -283,6 +285,15 @@ export async function statusCommand( nodeService: nodeDaemon, nodeOnlyGateway, }); + const updateRestartValue = formatUpdateRestartStatusValue( + (await readRestartSentinel().catch(() => null))?.payload, + { + ok, + warn, + muted, + formatTimeAgo, + }, + ); const lines = await buildStatusCommandReportLines( await buildStatusCommandReportData({ opts, @@ -322,6 +333,7 @@ export async function statusCommand( updateValue: updateSurface.updateAvailable ? warn(`available · ${updateSurface.updateLine}`) : updateSurface.updateLine, + updateRestartValue, }), ); for (const line of lines) {