fix(gateway): default restart acknowledgement continuations

This commit is contained in:
Ayaan Zaidi
2026-04-22 22:19:02 +05:30
parent 0e7bcf7588
commit b982d9e669
10 changed files with 319 additions and 13 deletions

View File

@@ -141,4 +141,30 @@ describe("gateway tool restart continuation", () => {
});
expect(result?.details).toEqual({ scheduled: true, delayMs: 250 });
});
it("defaults session-scoped restarts to a success continuation", async () => {
const { createGatewayTool } = await import("./gateway-tool.js");
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
await import("../../infra/restart-sentinel.js");
const tool = createGatewayTool({
agentSessionKey: "agent:main:main",
config: {},
});
await tool.execute?.("tool-call-1", {
action: "restart",
delayMs: 250,
reason: "restart requested",
});
expect(writeRestartSentinelMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
continuation: {
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
},
}),
);
});
});

View File

@@ -6,6 +6,7 @@ import { applyMergePatch } from "../../config/merge-patch.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
buildRestartSuccessContinuation,
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
writeRestartSentinel,
@@ -350,17 +351,11 @@ export function createGatewayTool(opts?: {
deliveryContext,
threadId,
message: note ?? reason ?? null,
continuation: continuationMessage
? continuationKind === "systemEvent"
? {
kind: "systemEvent",
text: continuationMessage,
}
: {
kind: "agentTurn",
message: continuationMessage,
}
: null,
continuation: buildRestartSuccessContinuation({
sessionKey,
continuationKind,
continuationMessage,
}),
doctorHint: formatDoctorNonInteractiveHint(),
stats: {
mode: "gateway.restart",

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js";
import type { HandleCommandsParams } from "./commands-types.js";
const mocks = vi.hoisted(() => ({
isRestartEnabled: vi.fn(() => true),
extractDeliveryInfo: vi.fn(() => ({
deliveryContext: {
channel: "telegram",
to: "telegram:123",
accountId: "default",
},
threadId: "thread-1",
})),
formatDoctorNonInteractiveHint: vi.fn(() => "Run: openclaw doctor --non-interactive"),
writeRestartSentinel: vi.fn(async (_payload: RestartSentinelPayload) => "/tmp/sentinel.json"),
scheduleGatewaySigusr1Restart: vi.fn(() => ({ scheduled: true })),
triggerOpenClawRestart: vi.fn(() => ({ ok: true, method: "launchctl" })),
}));
vi.mock("../../config/commands.flags.js", () => ({
isRestartEnabled: mocks.isRestartEnabled,
}));
vi.mock("../../config/sessions.js", () => ({
extractDeliveryInfo: mocks.extractDeliveryInfo,
}));
vi.mock("../../globals.js", () => ({
logVerbose: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: vi.fn(),
normalizeChannelId: (value?: string | null) => value?.trim().toLowerCase() ?? null,
}));
vi.mock("../../channels/plugins/conversation-bindings.js", () => ({
setChannelConversationBindingIdleTimeoutBySessionKey: vi.fn(),
setChannelConversationBindingMaxAgeBySessionKey: vi.fn(),
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: vi.fn(),
}));
vi.mock("../../infra/restart-sentinel.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/restart-sentinel.js")>(
"../../infra/restart-sentinel.js",
);
return {
...actual,
formatDoctorNonInteractiveHint: mocks.formatDoctorNonInteractiveHint,
writeRestartSentinel: mocks.writeRestartSentinel,
};
});
vi.mock("../../infra/restart.js", () => ({
scheduleGatewaySigusr1Restart: mocks.scheduleGatewaySigusr1Restart,
triggerOpenClawRestart: mocks.triggerOpenClawRestart,
}));
const { handleRestartCommand } = await import("./commands-session.js");
function restartCommandParams(overrides?: Partial<HandleCommandsParams>): HandleCommandsParams {
return {
ctx: {},
cfg: {},
command: {
surface: "telegram",
channel: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "user-1",
rawBodyNormalized: "/restart",
commandBodyNormalized: "/restart",
from: "telegram:123",
to: "bot",
},
directives: {},
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:telegram:direct:123:thread:thread-1",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
provider: "openai",
model: "gpt-5.4",
contextTokens: 0,
isGroup: false,
...overrides,
} as HandleCommandsParams;
}
describe("handleRestartCommand", () => {
beforeEach(() => {
mocks.isRestartEnabled.mockReset();
mocks.isRestartEnabled.mockReturnValue(true);
mocks.extractDeliveryInfo.mockClear();
mocks.formatDoctorNonInteractiveHint.mockClear();
mocks.writeRestartSentinel.mockClear();
mocks.scheduleGatewaySigusr1Restart.mockClear();
mocks.triggerOpenClawRestart.mockReset();
mocks.triggerOpenClawRestart.mockReturnValue({ ok: true, method: "launchctl" });
});
it("writes a routed restart sentinel before restarting from chat", async () => {
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
await import("../../infra/restart-sentinel.js");
const result = await handleRestartCommand(restartCommandParams(), true);
expect(result?.shouldContinue).toBe(false);
expect(mocks.writeRestartSentinel).toHaveBeenCalledWith(
expect.objectContaining({
kind: "restart",
status: "ok",
sessionKey: "agent:main:telegram:direct:123:thread:thread-1",
deliveryContext: {
channel: "telegram",
to: "telegram:123",
accountId: "default",
},
threadId: "thread-1",
message: "/restart",
continuation: {
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
},
}),
);
expect(mocks.triggerOpenClawRestart).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,9 +8,16 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind
import { formatThreadBindingDurationLabel } from "../../channels/thread-bindings-messages.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { isRestartEnabled } from "../../config/commands.flags.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import {
buildRestartSuccessContinuation,
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
import {
@@ -26,7 +33,7 @@ import { resolveCommandSurfaceChannel } from "./channel-context.js";
import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js";
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
import { persistSessionEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
import { resolveConversationBindingContextFromAcpCommand } from "./conversation-binding-input.js";
const SESSION_COMMAND_PREFIX = "/session";
@@ -34,6 +41,30 @@ const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none
const SESSION_ACTION_IDLE = "idle";
const SESSION_ACTION_MAX_AGE = "max-age";
async function writeRestartCommandSentinel(params: HandleCommandsParams) {
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!sessionKey) {
return;
}
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey);
const payload: RestartSentinelPayload = {
kind: "restart",
status: "ok",
ts: Date.now(),
sessionKey,
deliveryContext,
threadId,
message: "/restart",
continuation: buildRestartSuccessContinuation({ sessionKey }),
doctorHint: formatDoctorNonInteractiveHint(),
stats: {
mode: "gateway.restart",
reason: "/restart",
},
};
await writeRestartSentinel(payload).catch(() => {});
}
function resolveSessionCommandUsage() {
return "Usage: /session idle <duration|off> | /session max-age <duration|off> (example: /session idle 24h)";
}
@@ -641,6 +672,7 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
}
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
if (hasSigusr1Listener) {
await writeRestartCommandSentinel(params);
scheduleGatewaySigusr1Restart({ reason: "/restart" });
return {
shouldContinue: false,
@@ -649,6 +681,7 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
},
};
}
await writeRestartCommandSentinel(params);
const restartMethod = triggerOpenClawRestart();
if (!restartMethod.ok) {
const detail = restartMethod.detail ? ` Details: ${restartMethod.detail}` : "";

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js";
import {
createConfigHandlerHarness,
createConfigWriteSnapshot,
@@ -15,6 +16,11 @@ const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({
delayMs: 1_000,
coalesced: false,
}));
const restartSentinelMocks = vi.hoisted(() => ({
writeRestartSentinel: vi.fn(async (_payload: RestartSentinelPayload) => {
return "/tmp/restart-sentinel.json";
}),
}));
vi.mock("../../config/config.js", async () => {
const actual =
@@ -40,6 +46,16 @@ vi.mock("../../infra/restart.js", () => ({
scheduleGatewaySigusr1Restart: scheduleGatewaySigusr1RestartMock,
}));
vi.mock("../../infra/restart-sentinel.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/restart-sentinel.js")>(
"../../infra/restart-sentinel.js",
);
return {
...actual,
writeRestartSentinel: restartSentinelMocks.writeRestartSentinel,
};
});
const { configHandlers } = await import("./config.js");
afterEach(() => {
@@ -52,6 +68,7 @@ beforeEach(() => {
config,
}));
prepareSecretsRuntimeSnapshotMock.mockResolvedValue(undefined);
restartSentinelMocks.writeRestartSentinel.mockClear();
});
describe("config shared auth disconnects", () => {
@@ -168,4 +185,39 @@ describe("config shared auth disconnects", () => {
expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1);
});
it("adds a default continuation to session-scoped restart sentinels", async () => {
const { DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE } =
await import("../../infra/restart-sentinel.js");
const prevConfig: OpenClawConfig = {
gateway: {
reload: {
mode: "hot",
},
},
};
readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig));
const { options } = createConfigHandlerHarness({
method: "config.patch",
params: {
baseHash: "base-hash",
raw: JSON.stringify({ gateway: { port: 19001 } }),
restartDelayMs: 1_000,
sessionKey: "agent:main:main",
},
});
await configHandlers["config.patch"](options);
expect(restartSentinelMocks.writeRestartSentinel).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
continuation: {
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
},
}),
);
});
});

View File

@@ -22,6 +22,7 @@ import { extractDeliveryInfo } from "../../config/sessions.js";
import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
buildRestartSuccessContinuation,
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
writeRestartSentinel,
@@ -367,6 +368,7 @@ function buildConfigRestartSentinelPayload(params: {
deliveryContext: params.deliveryContext,
threadId: params.threadId,
message: params.note ?? null,
continuation: buildRestartSuccessContinuation({ sessionKey: params.sessionKey }),
doctorHint: formatDoctorNonInteractiveHint(),
stats: {
mode: params.mode,

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 { UpdateRunResult } from "../../infra/update-runner.js";
// Capture the sentinel payload written during update.run
@@ -122,6 +125,10 @@ describe("update.run sentinel deliveryContext", () => {
to: "webchat:user-123",
accountId: "default",
});
expect(capturedPayload!.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
});
it("omits deliveryContext when no sessionKey is provided", async () => {
@@ -132,6 +139,7 @@ describe("update.run sentinel deliveryContext", () => {
expect(capturedPayload).toBeDefined();
expect(capturedPayload!.deliveryContext).toBeUndefined();
expect(capturedPayload!.threadId).toBeUndefined();
expect(capturedPayload!.continuation).toBeNull();
});
it("includes threadId in sentinel payload for threaded sessions", async () => {
@@ -146,6 +154,10 @@ describe("update.run sentinel deliveryContext", () => {
accountId: "workspace-1",
});
expect(capturedPayload!.threadId).toBe("1234567890.123456");
expect(capturedPayload!.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
});
});
@@ -194,5 +206,6 @@ describe("update.run restart scheduling", () => {
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
expect(payload?.ok).toBe(false);
expect(payload?.restart).toBeNull();
expect(capturedPayload?.continuation).toBeNull();
});
});

View File

@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
import {
buildRestartSuccessContinuation,
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
writeRestartSentinel,
@@ -72,6 +73,7 @@ export const updateHandlers: GatewayRequestHandlers = {
deliveryContext,
threadId,
message: note ?? null,
continuation: result.status === "ok" ? buildRestartSuccessContinuation({ sessionKey }) : null,
doctorHint: formatDoctorNonInteractiveHint(),
stats: {
mode: result.mode,

View File

@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
import {
DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
buildRestartSuccessContinuation,
consumeRestartSentinel,
formatDoctorNonInteractiveHint,
formatRestartSentinelMessage,
@@ -184,6 +186,32 @@ describe("restart sentinel", () => {
});
});
describe("restart success continuation", () => {
it("builds the default agent turn for session-scoped restarts", () => {
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
});
it("keeps explicit continuation messages", () => {
expect(
buildRestartSuccessContinuation({
sessionKey: "agent:main:main",
continuationKind: "systemEvent",
continuationMessage: "wake after restart",
}),
).toEqual({
kind: "systemEvent",
text: "wake after restart",
});
});
it("stays silent without session context", () => {
expect(buildRestartSuccessContinuation({})).toBeNull();
});
});
describe("restart sentinel message dedup", () => {
it("omits duplicate Reason: line when stats.reason matches message", () => {
const payload = {

View File

@@ -62,6 +62,9 @@ export type RestartSentinel = {
payload: RestartSentinelPayload;
};
export const DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE =
"The gateway restart completed successfully. Tell the user OpenClaw restarted successfully and continue any pending work.";
const SENTINEL_FILENAME = "restart-sentinel.json";
export function formatDoctorNonInteractiveHint(
@@ -84,6 +87,22 @@ export async function writeRestartSentinel(
return filePath;
}
export function buildRestartSuccessContinuation(params: {
sessionKey?: string;
continuationKind?: string | null;
continuationMessage?: string | null;
}): RestartSentinelContinuation | null {
const message = params.continuationMessage?.trim();
if (message) {
return params.continuationKind === "systemEvent"
? { kind: "systemEvent", text: message }
: { kind: "agentTurn", message };
}
return params.sessionKey?.trim()
? { kind: "agentTurn", message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE }
: null;
}
export async function readRestartSentinel(
env: NodeJS.ProcessEnv = process.env,
): Promise<RestartSentinel | null> {