diff --git a/docs/cli/update.md b/docs/cli/update.md index ac75796ccba8..938a4680c532 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -199,8 +199,8 @@ health checks complete. During the handoff, the sentinel can carry 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. `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. +row while that sentinel is pending or failed, and `update.status` refreshes and +returns the latest sentinel. ## Git checkout flow diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index f11e2d1eb594..8a53136787c8 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -412,7 +412,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists. - `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`. - `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel. - - `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available. + - `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available. - `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC. diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 8ead6d3ed78a..c9d8e61e2f08 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -16,6 +16,9 @@ const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise RestartSentinelPayload | null>(() => null); +const refreshLatestUpdateRestartSentinelMock = vi.fn<() => Promise>( + async () => null, +); const recordLatestUpdateRestartSentinelMock = vi.fn(); const isRestartEnabledMock = vi.fn(() => true); const readPackageVersionMock = vi.fn(async () => "1.0.0"); @@ -114,6 +117,7 @@ vi.mock("../../../packages/gateway-protocol/src/index.js", () => ({ vi.mock("../server-restart-sentinel.js", () => ({ getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock, recordLatestUpdateRestartSentinel: recordLatestUpdateRestartSentinelMock, + refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelMock, })); vi.mock("./restart-request.js", () => ({ @@ -166,6 +170,8 @@ beforeEach(() => { packageRoot: "/tmp/openclaw", }); getLatestUpdateRestartSentinelMock.mockClear(); + refreshLatestUpdateRestartSentinelMock.mockClear(); + refreshLatestUpdateRestartSentinelMock.mockResolvedValue(null); recordLatestUpdateRestartSentinelMock.mockClear(); startManagedServiceUpdateHandoffMock.mockClear(); scheduleGatewaySigusr1RestartMock.mockClear(); @@ -598,11 +604,19 @@ describe("update.run restart scheduling", () => { }); describe("update.status", () => { - it("returns the latest cached update sentinel", async () => { + it("refreshes the latest update sentinel before responding", async () => { getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ kind: "update", - status: "ok", + status: "skipped", ts: 1, + stats: { + reason: "restart-health-pending", + }, + }); + refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({ + kind: "update", + status: "ok", + ts: 2, stats: { after: { version: "2.0.0" }, }, @@ -621,7 +635,37 @@ describe("update.status", () => { { sentinel?: { kind?: string; status?: string } } | undefined, ]; expect(ok).toBe(true); + expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1); expect(response?.sentinel?.kind).toBe("update"); expect(response?.sentinel?.status).toBe("ok"); }); + + it("falls back to the cached update sentinel when refresh fails", async () => { + refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed")); + getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ + kind: "update", + status: "skipped", + ts: 1, + stats: { + reason: "restart-health-pending", + }, + }); + const warn = vi.fn(); + const { updateHandlers } = await import("./update.js"); + const respond = vi.fn(); + + await updateHandlers["update.status"]({ + params: {}, + respond, + context: { logGateway: { warn } }, + } as never); + + expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed"); + const [, response] = firstMockCall(respond, "update status response") as [ + boolean, + { sentinel?: { kind?: string; status?: string } } | undefined, + ]; + expect(response?.sentinel?.kind).toBe("update"); + expect(response?.sentinel?.status).toBe("skipped"); + }); }); diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index ec0839bc76db..7c6143811342 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -30,6 +30,7 @@ import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-pl import { getLatestUpdateRestartSentinel, recordLatestUpdateRestartSentinel, + refreshLatestUpdateRestartSentinel, } from "../server-restart-sentinel.js"; import { parseRestartRequestParams } from "./restart-request.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -98,12 +99,21 @@ function hasManagedServiceHandoffContext( } export const updateHandlers: GatewayRequestHandlers = { - "update.status": async ({ params, respond }) => { + "update.status": async ({ params, respond, context }) => { if (!assertValidParams(params, validateUpdateStatusParams, "update.status", respond)) { return; } + let sentinel: RestartSentinelPayload | null; + try { + sentinel = await refreshLatestUpdateRestartSentinel(); + } catch (err) { + context?.logGateway?.warn( + `update.status sentinel refresh failed: ${formatUpdateRunErrorMessage(err)}`, + ); + sentinel = getLatestUpdateRestartSentinel(); + } respond(true, { - sentinel: getLatestUpdateRestartSentinel(), + sentinel, }); }, "update.run": async ({ params, respond, client, context }) => { diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index 416d0814f85d..ee2794a8cc72 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -237,6 +237,37 @@ describe("restart sentinel", () => { }); }); + it("does not rewrite update sentinels when the running version is already current", async () => { + await withRestartSentinelStateDir(async () => { + const ts = Date.now(); + await writeRestartSentinel({ + kind: "update", + status: "ok", + ts, + stats: { + after: { version: "actual-version" }, + }, + }); + + await expect( + finalizeUpdateRestartSentinelRunningVersion("actual-version"), + ).resolves.toBeNull(); + await expect(readRestartSentinel()).resolves.toEqual({ + version: 1, + payload: { + kind: "update", + status: "ok", + ts, + stats: { + after: { + version: "actual-version", + }, + }, + }, + }); + }); + }); + it("marks update restart failures with a stable reason", async () => { await withRestartSentinelStateDir(async () => { const ts = Date.now(); diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index ed0ae2b2891c..81ade6c4f86e 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -124,6 +124,9 @@ export async function finalizeUpdateRestartSentinelRunningVersion( } const stats = payload.stats ? { ...payload.stats } : {}; const after = isPlainRecord(stats.after) ? { ...stats.after } : {}; + if (after.version === version) { + return null; + } after.version = version; stats.after = after; return {