mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix: refresh update status sentinel
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user