fix: refresh update status sentinel

This commit is contained in:
Shakker
2026-06-10 19:15:46 +01:00
committed by Shakker
parent 862790fbd7
commit 3394a4ad2c
6 changed files with 95 additions and 7 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
</Accordion>
+46 -2
View File
@@ -16,6 +16,9 @@ const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise<UpdateInstallSurface
packageRoot: "/tmp/openclaw",
}));
const getLatestUpdateRestartSentinelMock = vi.fn<() => RestartSentinelPayload | null>(() => null);
const refreshLatestUpdateRestartSentinelMock = vi.fn<() => Promise<RestartSentinelPayload | null>>(
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");
});
});
+12 -2
View File
@@ -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 }) => {
+31
View File
@@ -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();
+3
View File
@@ -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 {