Skip to content

Commit 3394a4a

Browse files
committed
fix: refresh update status sentinel
1 parent 862790f commit 3394a4a

6 files changed

Lines changed: 95 additions & 7 deletions

File tree

docs/cli/update.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ health checks complete. During the handoff, the sentinel can carry
199199
restarted Gateway keeps polling it and only fires the continuation after the CLI
200200
has verified service health and rewritten the sentinel with the final `ok`
201201
result. `openclaw status` and `openclaw status --all` show an `Update restart`
202-
row while that sentinel is pending or failed, and `update.status` returns the
203-
latest cached sentinel.
202+
row while that sentinel is pending or failed, and `update.status` refreshes and
203+
returns the latest sentinel.
204204

205205
## Git checkout flow
206206

docs/gateway/protocol.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
412412
- `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.
413413
- `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`.
414414
- `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.
415-
- `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available.
415+
- `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
416416
- `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
417417

418418
</Accordion>

src/gateway/server-methods/update.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise<UpdateInstallSurface
1616
packageRoot: "/tmp/openclaw",
1717
}));
1818
const getLatestUpdateRestartSentinelMock = vi.fn<() => RestartSentinelPayload | null>(() => null);
19+
const refreshLatestUpdateRestartSentinelMock = vi.fn<() => Promise<RestartSentinelPayload | null>>(
20+
async () => null,
21+
);
1922
const recordLatestUpdateRestartSentinelMock = vi.fn();
2023
const isRestartEnabledMock = vi.fn(() => true);
2124
const readPackageVersionMock = vi.fn(async () => "1.0.0");
@@ -114,6 +117,7 @@ vi.mock("../../../packages/gateway-protocol/src/index.js", () => ({
114117
vi.mock("../server-restart-sentinel.js", () => ({
115118
getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock,
116119
recordLatestUpdateRestartSentinel: recordLatestUpdateRestartSentinelMock,
120+
refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelMock,
117121
}));
118122

119123
vi.mock("./restart-request.js", () => ({
@@ -166,6 +170,8 @@ beforeEach(() => {
166170
packageRoot: "/tmp/openclaw",
167171
});
168172
getLatestUpdateRestartSentinelMock.mockClear();
173+
refreshLatestUpdateRestartSentinelMock.mockClear();
174+
refreshLatestUpdateRestartSentinelMock.mockResolvedValue(null);
169175
recordLatestUpdateRestartSentinelMock.mockClear();
170176
startManagedServiceUpdateHandoffMock.mockClear();
171177
scheduleGatewaySigusr1RestartMock.mockClear();
@@ -598,11 +604,19 @@ describe("update.run restart scheduling", () => {
598604
});
599605

600606
describe("update.status", () => {
601-
it("returns the latest cached update sentinel", async () => {
607+
it("refreshes the latest update sentinel before responding", async () => {
602608
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
603609
kind: "update",
604-
status: "ok",
610+
status: "skipped",
605611
ts: 1,
612+
stats: {
613+
reason: "restart-health-pending",
614+
},
615+
});
616+
refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({
617+
kind: "update",
618+
status: "ok",
619+
ts: 2,
606620
stats: {
607621
after: { version: "2.0.0" },
608622
},
@@ -621,7 +635,37 @@ describe("update.status", () => {
621635
{ sentinel?: { kind?: string; status?: string } } | undefined,
622636
];
623637
expect(ok).toBe(true);
638+
expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1);
624639
expect(response?.sentinel?.kind).toBe("update");
625640
expect(response?.sentinel?.status).toBe("ok");
626641
});
642+
643+
it("falls back to the cached update sentinel when refresh fails", async () => {
644+
refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed"));
645+
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
646+
kind: "update",
647+
status: "skipped",
648+
ts: 1,
649+
stats: {
650+
reason: "restart-health-pending",
651+
},
652+
});
653+
const warn = vi.fn();
654+
const { updateHandlers } = await import("./update.js");
655+
const respond = vi.fn();
656+
657+
await updateHandlers["update.status"]({
658+
params: {},
659+
respond,
660+
context: { logGateway: { warn } },
661+
} as never);
662+
663+
expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed");
664+
const [, response] = firstMockCall(respond, "update status response") as [
665+
boolean,
666+
{ sentinel?: { kind?: string; status?: string } } | undefined,
667+
];
668+
expect(response?.sentinel?.kind).toBe("update");
669+
expect(response?.sentinel?.status).toBe("skipped");
670+
});
627671
});

src/gateway/server-methods/update.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-pl
3030
import {
3131
getLatestUpdateRestartSentinel,
3232
recordLatestUpdateRestartSentinel,
33+
refreshLatestUpdateRestartSentinel,
3334
} from "../server-restart-sentinel.js";
3435
import { parseRestartRequestParams } from "./restart-request.js";
3536
import type { GatewayRequestHandlers } from "./types.js";
@@ -98,12 +99,21 @@ function hasManagedServiceHandoffContext(
9899
}
99100

100101
export const updateHandlers: GatewayRequestHandlers = {
101-
"update.status": async ({ params, respond }) => {
102+
"update.status": async ({ params, respond, context }) => {
102103
if (!assertValidParams(params, validateUpdateStatusParams, "update.status", respond)) {
103104
return;
104105
}
106+
let sentinel: RestartSentinelPayload | null;
107+
try {
108+
sentinel = await refreshLatestUpdateRestartSentinel();
109+
} catch (err) {
110+
context?.logGateway?.warn(
111+
`update.status sentinel refresh failed: ${formatUpdateRunErrorMessage(err)}`,
112+
);
113+
sentinel = getLatestUpdateRestartSentinel();
114+
}
105115
respond(true, {
106-
sentinel: getLatestUpdateRestartSentinel(),
116+
sentinel,
107117
});
108118
},
109119
"update.run": async ({ params, respond, client, context }) => {

src/infra/restart-sentinel.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,37 @@ describe("restart sentinel", () => {
237237
});
238238
});
239239

240+
it("does not rewrite update sentinels when the running version is already current", async () => {
241+
await withRestartSentinelStateDir(async () => {
242+
const ts = Date.now();
243+
await writeRestartSentinel({
244+
kind: "update",
245+
status: "ok",
246+
ts,
247+
stats: {
248+
after: { version: "actual-version" },
249+
},
250+
});
251+
252+
await expect(
253+
finalizeUpdateRestartSentinelRunningVersion("actual-version"),
254+
).resolves.toBeNull();
255+
await expect(readRestartSentinel()).resolves.toEqual({
256+
version: 1,
257+
payload: {
258+
kind: "update",
259+
status: "ok",
260+
ts,
261+
stats: {
262+
after: {
263+
version: "actual-version",
264+
},
265+
},
266+
},
267+
});
268+
});
269+
});
270+
240271
it("marks update restart failures with a stable reason", async () => {
241272
await withRestartSentinelStateDir(async () => {
242273
const ts = Date.now();

src/infra/restart-sentinel.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ export async function finalizeUpdateRestartSentinelRunningVersion(
124124
}
125125
const stats = payload.stats ? { ...payload.stats } : {};
126126
const after = isPlainRecord(stats.after) ? { ...stats.after } : {};
127+
if (after.version === version) {
128+
return null;
129+
}
127130
after.version = version;
128131
stats.after = after;
129132
return {

0 commit comments

Comments
 (0)