fix: continue update runs after restart (#74362) (thanks @100menotu001)

This commit is contained in:
Craig
2026-04-29 09:40:07 -04:00
committed by Ayaan Zaidi
parent fa533101d8
commit baf8b8effe
9 changed files with 58 additions and 10 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
}),

View File

@@ -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,
});

View File

@@ -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 })),
},

View File

@@ -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 };
}

View File

@@ -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<string, unknown>) => ({
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);

View File

@@ -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,