fix: surface update restart and plugin repair guidance

This commit is contained in:
Peter Steinberger
2026-05-15 07:09:34 +01:00
parent 0cbd2d3b08
commit b830beb34b
16 changed files with 511 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" <<JSON
{
"gateway": { "mode": "local" },
"plugins": {
"enabled": true,
"bundledDiscovery": "allowlist",
"load": { "paths": ["$PLUGIN_DIR"] },
"allow": ["e2e-corrupt-chat"]
},
"channels": {
"e2e-corrupt-chat": { "enabled": true, "token": "configured" }
}
}
JSON
run_openclaw() {
HOME="$HOME_DIR" \
OPENCLAW_HOME="$STATE_DIR" \
OPENCLAW_STATE_DIR="$STATE_DIR" \
OPENCLAW_CONFIG_PATH="$CONFIG_PATH" \
OPENCLAW_PLUGIN_STAGE_DIR="$STAGE_DIR" \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_NO_PROMPT=1 \
OPENCLAW_SKIP_CHANNELS=1 \
OPENCLAW_SKIP_PROVIDERS=1 \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
NO_COLOR=1 \
node "$ROOT_DIR/scripts/run-node.mjs" "$@"
}
BEFORE="$TMP_DIR/status-before.txt"
DOCTOR="$TMP_DIR/doctor.txt"
AFTER="$TMP_DIR/status-after.txt"
run_openclaw status --all --timeout 1 > "$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."

View File

@@ -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);
});

View File

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

View File

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

View File

@@ -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");
}

View File

@@ -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,
});

View File

@@ -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");
});

View File

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

View File

@@ -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([]);
});
});

View File

@@ -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 [];
}

View File

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

View File

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