mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:04:47 +00:00
fix: surface update restart and plugin repair guidance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
155
scripts/e2e/status-corrupt-plugin-deps.sh
Normal file
155
scripts/e2e/status-corrupt-plugin-deps.sh
Normal 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."
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
82
src/commands/status-update-restart.test.ts
Normal file
82
src/commands/status-update-restart.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
86
src/commands/status-update-restart.ts
Normal file
86
src/commands/status-update-restart.ts
Normal 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 [];
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user