From b982d9e669101ea1d2ad1fdcffc31b3ad361a246 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 22 Apr 2026 22:19:02 +0530 Subject: [PATCH] fix(gateway): default restart acknowledgement continuations --- src/agents/tools/gateway-tool.test.ts | 26 ++++ src/agents/tools/gateway-tool.ts | 17 +-- .../reply/commands-session-restart.test.ts | 136 ++++++++++++++++++ src/auto-reply/reply/commands-session.ts | 35 ++++- .../server-methods/config.shared-auth.test.ts | 52 +++++++ src/gateway/server-methods/config.ts | 2 + src/gateway/server-methods/update.test.ts | 15 +- src/gateway/server-methods/update.ts | 2 + src/infra/restart-sentinel.test.ts | 28 ++++ src/infra/restart-sentinel.ts | 19 +++ 10 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 src/auto-reply/reply/commands-session-restart.test.ts diff --git a/src/agents/tools/gateway-tool.test.ts b/src/agents/tools/gateway-tool.test.ts index 07fe109642b..fda259d2f34 100644 --- a/src/agents/tools/gateway-tool.test.ts +++ b/src/agents/tools/gateway-tool.test.ts @@ -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, + }, + }), + ); + }); }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 66a6f3a157d..98f8e72ca5a 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -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", diff --git a/src/auto-reply/reply/commands-session-restart.test.ts b/src/auto-reply/reply/commands-session-restart.test.ts new file mode 100644 index 00000000000..c6099d31ad6 --- /dev/null +++ b/src/auto-reply/reply/commands-session-restart.test.ts @@ -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( + "../../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 { + 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); + }); +}); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 9941e792f0f..4ba7f2332c5 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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 | /session max-age (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}` : ""; diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index e01c27f18dd..b97baaf964c 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -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( + "../../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, + }, + }), + ); + }); }); diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 009b9c8d380..57f85f5fc2c 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -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, diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 0ea25bbdaf2..d0cc726c52f 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 { 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(); }); }); diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 47f4f0fe8d9..ca9bc97a9d4 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -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, diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index ee7f3915ef8..a8ba1136510 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -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 = { diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 3faef4cada5..63eeb2cee2e 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -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 {