From baf8b8effec58257110c750e5b6567548540f775 Mon Sep 17 00:00:00 2001 From: Craig Date: Wed, 29 Apr 2026 09:40:07 -0400 Subject: [PATCH] fix: continue update runs after restart (#74362) (thanks @100menotu001) --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 2 +- docs/gateway/protocol.md | 2 +- src/agents/openclaw-gateway-tool.test.ts | 2 + src/agents/tools/gateway-tool.ts | 2 + src/gateway/protocol/schema/config.ts | 1 + src/gateway/server-methods/restart-request.ts | 6 ++- src/gateway/server-methods/update.test.ts | 45 ++++++++++++++++--- src/gateway/server-methods/update.ts | 7 +++ 9 files changed, 58 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fba7c047b80..6da1ff8b109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai - Providers/Ollama: restore catalog context-window forwarding as `num_ctx` for native `/api/chat` requests; fixes tool selection and context truncation regressions on models with catalog entries (qwen3, llama3, gemma3, …) when no explicit `params.num_ctx` was configured. Fixes #76117. (#76181) Thanks @openperf. - Plugins/providers: preserve scoped cold-load fallback for enabled external manifest-contract capability providers missing from the startup registry, so providers such as Fish Audio can resolve on request without requiring `activation.onStartup` for correctness. (#76536) Thanks @Conan-Scott. +- Gateway/update: carry `continuationMessage` from `update.run` into successful restart sentinels so session-scoped self-updates can resume one follow-up turn after the Gateway restarts. Refs #71178. (#74362) Thanks @100menotu001, @HeilbronAILabs, and @artnking. ## 2026.5.2 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3dd3b67af77..6be45bb972c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -609,7 +609,7 @@ For tooling that writes config over the gateway API, prefer this flow: - `config.patch` for partial updates (JSON merge patch: objects merge, `null` deletes, arrays replace) - `config.apply` only when you intend to replace the entire config -- `update.run` for explicit self-update plus restart +- `update.run` for explicit self-update plus restart; include `continuationMessage` when the post-restart session should run one follow-up turn - `update.status` to inspect the latest update restart sentinel and verify the running version after a restart Agents should treat `config.schema.lookup` as the first stop for exact diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index b15ee802bf5..d57afd02920 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -381,7 +381,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - `config.apply` validates + replaces the full config payload. - `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`, and immediate child summaries for UI/CLI drill-down. 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`, plus the matched `hint` / `hintPath`. - - `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded. Package-manager updates force a non-deferred, no-cooldown update restart after the package swap so the old Gateway process does not keep lazy-loading from a replaced `dist` tree. + - `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 force a non-deferred, no-cooldown update restart after the package swap so the old Gateway process does not keep lazy-loading from a replaced `dist` tree. - `update.status` returns the latest cached 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/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index 1819ace83a1..ff808104b96 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -708,12 +708,14 @@ describe("gateway tool", () => { await tool.execute("call3", { action: "update.run", note: "test update", + continuationMessage: "Report the update result after restart.", }); expect(callGatewayTool).toHaveBeenCalledWith( "update.run", expect.any(Object), expect.objectContaining({ + continuationMessage: "Report the update result after restart.", note: "test update", sessionKey, }), diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index e4415cd3833..fb2c1d6e8b6 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -519,6 +519,7 @@ export function createGatewayTool(opts?: { } if (action === "update.run") { const { sessionKey, note, restartDelayMs } = resolveGatewayWriteMeta(); + const continuationMessage = normalizeOptionalString(params.continuationMessage); const updateTimeoutMs = gatewayOpts.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS; const updateGatewayOpts = { ...gatewayOpts, @@ -527,6 +528,7 @@ export function createGatewayTool(opts?: { const result = await callGatewayTool("update.run", updateGatewayOpts, { sessionKey, note, + continuationMessage, restartDelayMs, timeoutMs: updateTimeoutMs, }); diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index 7e879757532..554cb710d69 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -58,6 +58,7 @@ export const UpdateRunParamsSchema = Type.Object( sessionKey: Type.Optional(Type.String()), deliveryContext: Type.Optional(ConfigDeliveryContextSchema), note: Type.Optional(Type.String()), + continuationMessage: Type.Optional(Type.String()), restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, diff --git a/src/gateway/server-methods/restart-request.ts b/src/gateway/server-methods/restart-request.ts index cba29546289..7b9af5709c5 100644 --- a/src/gateway/server-methods/restart-request.ts +++ b/src/gateway/server-methods/restart-request.ts @@ -39,15 +39,19 @@ export function parseRestartRequestParams(params: unknown): { deliveryContext: RestartDeliveryContext | undefined; threadId: string | undefined; note: string | undefined; + continuationMessage: string | undefined; restartDelayMs: number | undefined; } { const sessionKey = normalizeOptionalString((params as { sessionKey?: unknown }).sessionKey); const { deliveryContext, threadId } = parseRestartDeliveryContext(params); const note = normalizeOptionalString((params as { note?: unknown }).note); + const continuationMessage = normalizeOptionalString( + (params as { continuationMessage?: unknown }).continuationMessage, + ); const restartDelayMsRaw = (params as { restartDelayMs?: unknown }).restartDelayMs; const restartDelayMs = typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw) ? Math.max(0, Math.floor(restartDelayMsRaw)) : undefined; - return { sessionKey, deliveryContext, threadId, note, restartDelayMs }; + return { sessionKey, deliveryContext, threadId, note, continuationMessage, restartDelayMs }; } diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 194d0d405ed..c96e5fb82bb 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; +import { + DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE, + type RestartSentinelPayload, +} from "../../infra/restart-sentinel.js"; import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js"; // Capture the sentinel payload written during update.run @@ -102,6 +105,7 @@ vi.mock("./restart-request.js", () => ({ parseRestartRequestParams: (params: Record) => ({ sessionKey: params.sessionKey, note: params.note, + continuationMessage: params.continuationMessage, restartDelayMs: undefined, }), })); @@ -167,7 +171,10 @@ describe("update.run sentinel deliveryContext", () => { to: "webchat:user-123", accountId: "default", }); - expect(capturedPayload!.continuation).toBeUndefined(); + expect(capturedPayload!.continuation).toEqual({ + kind: "agentTurn", + message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE, + }); }); it("omits deliveryContext when no sessionKey is provided", async () => { @@ -193,7 +200,25 @@ describe("update.run sentinel deliveryContext", () => { accountId: "workspace-1", }); expect(capturedPayload!.threadId).toBe("1234567890.123456"); - expect(capturedPayload!.continuation).toBeUndefined(); + expect(capturedPayload!.continuation).toEqual({ + kind: "agentTurn", + message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE, + }); + }); + + it("uses an explicit continuationMessage in successful update sentinels", async () => { + capturedPayload = undefined; + + await invokeUpdateRun({ + sessionKey: "agent:main:webchat:dm:user-123", + continuationMessage: "Check the running version and finish the update report.", + }); + + expect(capturedPayload).toBeDefined(); + expect(capturedPayload!.continuation).toEqual({ + kind: "agentTurn", + message: "Check the running version and finish the update report.", + }); }); }); @@ -234,10 +259,16 @@ describe("update.run restart scheduling", () => { let payload: { ok: boolean; restart: unknown } | undefined; - await invokeUpdateRun({}, (_ok: boolean, response: unknown) => { - const typed = response as { ok: boolean; restart: unknown }; - payload = typed; - }); + await invokeUpdateRun( + { + sessionKey: "agent:main:webchat:dm:user-123", + continuationMessage: "This should not run after a failed update.", + }, + (_ok: boolean, response: unknown) => { + const typed = response as { ok: boolean; restart: unknown }; + payload = typed; + }, + ); expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); expect(payload?.ok).toBe(false); diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 121772122c1..cf993a8364f 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -3,6 +3,7 @@ import { extractDeliveryInfo } from "../../config/sessions.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; import { readPackageVersion } from "../../infra/package-json.js"; import { + buildRestartSuccessContinuation, formatDoctorNonInteractiveHint, type RestartSentinelPayload, writeRestartSentinel, @@ -40,6 +41,7 @@ export const updateHandlers: GatewayRequestHandlers = { deliveryContext: requestedDeliveryContext, threadId: requestedThreadId, note, + continuationMessage, restartDelayMs, } = parseRestartRequestParams(params); const { deliveryContext: sessionDeliveryContext, threadId: sessionThreadId } = @@ -99,6 +101,10 @@ export const updateHandlers: GatewayRequestHandlers = { }; } + const continuation = + result.status === "ok" + ? buildRestartSuccessContinuation({ sessionKey, continuationMessage }) + : null; const payload: RestartSentinelPayload = { kind: "update", status: result.status, @@ -107,6 +113,7 @@ export const updateHandlers: GatewayRequestHandlers = { deliveryContext, threadId, message: note ?? null, + ...(continuation ? { continuation } : {}), doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: result.mode,